Tightly integrate WhatsApp selectors into existing UIs
This commit is contained in:
@@ -46,6 +46,10 @@ COMPOSE_WS_TOKEN_SALT = "compose-ws"
|
||||
COMPOSE_ENGAGE_TOKEN_SALT = "compose-engage"
|
||||
COMPOSE_AI_CACHE_TTL = 60 * 30
|
||||
URL_PATTERN = re.compile(r"https?://[^\s<>'\"\\]+")
|
||||
SIGNAL_UUID_PATTERN = re.compile(
|
||||
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
IMAGE_EXTENSIONS = (
|
||||
".png",
|
||||
".jpg",
|
||||
@@ -1349,6 +1353,42 @@ def _service_icon_class(service: str) -> str:
|
||||
return "fa-solid fa-address-card"
|
||||
|
||||
|
||||
def _service_label(service: str) -> str:
|
||||
key = str(service or "").strip().lower()
|
||||
labels = {
|
||||
"signal": "Signal",
|
||||
"whatsapp": "WhatsApp",
|
||||
"instagram": "Instagram",
|
||||
"xmpp": "XMPP",
|
||||
}
|
||||
return labels.get(key, key.title() if key else "Unknown")
|
||||
|
||||
|
||||
def _service_order(service: str) -> int:
|
||||
key = str(service or "").strip().lower()
|
||||
order = {
|
||||
"signal": 0,
|
||||
"whatsapp": 1,
|
||||
"instagram": 2,
|
||||
"xmpp": 3,
|
||||
}
|
||||
return order.get(key, 99)
|
||||
|
||||
|
||||
def _signal_identifier_shape(value: str) -> str:
|
||||
raw = str(value or "").strip()
|
||||
if not raw:
|
||||
return "unknown"
|
||||
if SIGNAL_UUID_PATTERN.fullmatch(raw):
|
||||
return "uuid"
|
||||
digits = re.sub(r"[^0-9]", "", raw)
|
||||
if digits and raw.replace("+", "").replace(" ", "").replace("-", "").isdigit():
|
||||
return "phone"
|
||||
if digits and raw.isdigit():
|
||||
return "phone"
|
||||
return "other"
|
||||
|
||||
|
||||
def _manual_contact_rows(user):
|
||||
rows = []
|
||||
seen = set()
|
||||
@@ -1397,6 +1437,7 @@ def _manual_contact_rows(user):
|
||||
{
|
||||
"person_name": person_name,
|
||||
"linked_person_name": linked_person_name,
|
||||
"person_id": str(person.id) if person else "",
|
||||
"detected_name": detected,
|
||||
"service": service_key,
|
||||
"service_icon_class": _service_icon_class(service_key),
|
||||
@@ -1489,7 +1530,94 @@ def _manual_contact_rows(user):
|
||||
detected_name=detected_name,
|
||||
)
|
||||
|
||||
rows.sort(key=lambda row: (row["person_name"].lower(), row["service"], row["identifier"]))
|
||||
rows.sort(
|
||||
key=lambda row: (
|
||||
0 if row.get("linked_person") else 1,
|
||||
row["person_name"].lower(),
|
||||
_service_order(row.get("service")),
|
||||
row["identifier"],
|
||||
)
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _recent_manual_contacts(
|
||||
user,
|
||||
*,
|
||||
current_service: str,
|
||||
current_identifier: str,
|
||||
current_person: Person | None,
|
||||
limit: int = 12,
|
||||
):
|
||||
all_rows = _manual_contact_rows(user)
|
||||
if not all_rows:
|
||||
return []
|
||||
|
||||
row_by_key = {
|
||||
(str(row.get("service") or "").strip().lower(), str(row.get("identifier") or "").strip()): row
|
||||
for row in all_rows
|
||||
}
|
||||
ordered_keys = []
|
||||
seen_keys = set()
|
||||
recent_values = (
|
||||
Message.objects.filter(
|
||||
user=user,
|
||||
session__identifier__isnull=False,
|
||||
)
|
||||
.values_list(
|
||||
"session__identifier__service",
|
||||
"session__identifier__identifier",
|
||||
)
|
||||
.order_by("-ts", "-id")[:1000]
|
||||
)
|
||||
for service_value, identifier_value in recent_values:
|
||||
key = (
|
||||
_default_service(service_value),
|
||||
str(identifier_value or "").strip(),
|
||||
)
|
||||
if not key[1] or key in seen_keys:
|
||||
continue
|
||||
seen_keys.add(key)
|
||||
ordered_keys.append(key)
|
||||
if len(ordered_keys) >= limit:
|
||||
break
|
||||
|
||||
current_key = (_default_service(current_service), str(current_identifier or "").strip())
|
||||
if current_key[1]:
|
||||
if current_key in ordered_keys:
|
||||
ordered_keys.remove(current_key)
|
||||
ordered_keys.insert(0, current_key)
|
||||
if len(ordered_keys) > limit:
|
||||
ordered_keys = ordered_keys[:limit]
|
||||
|
||||
rows = []
|
||||
for service_key, identifier_value in ordered_keys:
|
||||
row = dict(row_by_key.get((service_key, identifier_value)) or {})
|
||||
if not row:
|
||||
urls = _compose_urls(
|
||||
service_key,
|
||||
identifier_value,
|
||||
current_person.id if current_person else None,
|
||||
)
|
||||
row = {
|
||||
"person_name": identifier_value,
|
||||
"linked_person_name": "",
|
||||
"detected_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": False,
|
||||
"source": "recent",
|
||||
}
|
||||
row["service_label"] = _service_label(service_key)
|
||||
row["person_id"] = str(row.get("person_id") or "")
|
||||
row["is_active"] = (
|
||||
service_key == _default_service(current_service)
|
||||
and identifier_value == str(current_identifier or "").strip()
|
||||
)
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
@@ -1591,7 +1719,59 @@ def _panel_context(
|
||||
identifier=base["identifier"],
|
||||
person_id=base["person"].id if base["person"] else None,
|
||||
)
|
||||
ws_url = f"/ws/compose/thread/?{urlencode({'token': ws_token})}"
|
||||
ws_url = ""
|
||||
if bool(getattr(settings, "COMPOSE_WS_ENABLED", False)):
|
||||
ws_url = f"/ws/compose/thread/?{urlencode({'token': ws_token})}"
|
||||
|
||||
platform_options = []
|
||||
if base["person"] is not None:
|
||||
linked_identifiers = list(
|
||||
PersonIdentifier.objects.filter(
|
||||
user=request.user,
|
||||
person=base["person"],
|
||||
).order_by("service", "id")
|
||||
)
|
||||
by_service = {}
|
||||
for row in linked_identifiers:
|
||||
service_key = _default_service(row.service)
|
||||
identifier_value = str(row.identifier or "").strip()
|
||||
if not identifier_value:
|
||||
continue
|
||||
if service_key not in by_service:
|
||||
by_service[service_key] = identifier_value
|
||||
if base["service"] and base["identifier"]:
|
||||
by_service[base["service"]] = base["identifier"]
|
||||
|
||||
for service_key in sorted(by_service.keys(), key=_service_order):
|
||||
identifier_value = by_service[service_key]
|
||||
option_urls = _compose_urls(service_key, identifier_value, base["person"].id)
|
||||
platform_options.append(
|
||||
{
|
||||
"service": service_key,
|
||||
"service_label": _service_label(service_key),
|
||||
"identifier": identifier_value,
|
||||
"person_id": str(base["person"].id),
|
||||
"page_url": option_urls["page_url"],
|
||||
"widget_url": option_urls["widget_url"],
|
||||
"is_active": (
|
||||
service_key == base["service"]
|
||||
and identifier_value == base["identifier"]
|
||||
),
|
||||
}
|
||||
)
|
||||
elif base["identifier"]:
|
||||
option_urls = _compose_urls(base["service"], base["identifier"], None)
|
||||
platform_options.append(
|
||||
{
|
||||
"service": base["service"],
|
||||
"service_label": _service_label(base["service"]),
|
||||
"identifier": base["identifier"],
|
||||
"person_id": "",
|
||||
"page_url": option_urls["page_url"],
|
||||
"widget_url": option_urls["widget_url"],
|
||||
"is_active": True,
|
||||
}
|
||||
)
|
||||
|
||||
unique_raw = (
|
||||
f"{base['service']}|{base['identifier']}|{request.user.id}|{time.time_ns()}"
|
||||
@@ -1601,6 +1781,13 @@ def _panel_context(
|
||||
user_id=request.user.id,
|
||||
person_id=base["person"].id if base["person"] else None,
|
||||
)
|
||||
recent_contacts = _recent_manual_contacts(
|
||||
request.user,
|
||||
current_service=base["service"],
|
||||
current_identifier=base["identifier"],
|
||||
current_person=base["person"],
|
||||
limit=12,
|
||||
)
|
||||
|
||||
return {
|
||||
"service": base["service"],
|
||||
@@ -1627,6 +1814,7 @@ def _panel_context(
|
||||
"compose_engage_preview_url": reverse("compose_engage_preview"),
|
||||
"compose_engage_send_url": reverse("compose_engage_send"),
|
||||
"compose_quick_insights_url": reverse("compose_quick_insights"),
|
||||
"compose_history_sync_url": reverse("compose_history_sync"),
|
||||
"compose_ws_url": ws_url,
|
||||
"ai_workspace_url": (
|
||||
f"{reverse('ai_workspace')}?person={base['person'].id}"
|
||||
@@ -1644,6 +1832,8 @@ def _panel_context(
|
||||
"manual_icon_class": "fa-solid fa-paper-plane",
|
||||
"panel_id": f"compose-panel-{unique}",
|
||||
"typing_state_json": json.dumps(typing_state),
|
||||
"platform_options": platform_options,
|
||||
"recent_contacts": recent_contacts,
|
||||
}
|
||||
|
||||
|
||||
@@ -1757,6 +1947,28 @@ class ComposeContactMatch(LoginRequiredMixin, View):
|
||||
def get(self, request):
|
||||
return render(request, self.template_name, self._context(request))
|
||||
|
||||
def _signal_companion_identifiers(self, identifier: str) -> set[str]:
|
||||
value = str(identifier or "").strip()
|
||||
if not value:
|
||||
return set()
|
||||
source_shape = _signal_identifier_shape(value)
|
||||
companions = set()
|
||||
signal_rows = Chat.objects.filter(source_uuid=value) | Chat.objects.filter(
|
||||
source_number=value
|
||||
)
|
||||
for chat in signal_rows.order_by("-id")[:1000]:
|
||||
for candidate in (chat.source_uuid, chat.source_number):
|
||||
cleaned = str(candidate or "").strip()
|
||||
if not cleaned or cleaned == value:
|
||||
continue
|
||||
# Keep auto-linking conservative: only same-shape companions.
|
||||
if source_shape != "other":
|
||||
candidate_shape = _signal_identifier_shape(cleaned)
|
||||
if candidate_shape != source_shape:
|
||||
continue
|
||||
companions.add(cleaned)
|
||||
return companions
|
||||
|
||||
def post(self, request):
|
||||
person_id = str(request.POST.get("person_id") or "").strip()
|
||||
person_name = str(request.POST.get("person_name") or "").strip()
|
||||
@@ -1800,6 +2012,38 @@ class ComposeContactMatch(LoginRequiredMixin, View):
|
||||
message = f"Re-linked {identifier} ({service}) to {person.name}."
|
||||
else:
|
||||
message = f"{identifier} ({service}) is already linked to {person.name}."
|
||||
|
||||
linked_companions = 0
|
||||
skipped_companions = 0
|
||||
if service == "signal":
|
||||
companions = self._signal_companion_identifiers(identifier)
|
||||
for candidate in companions:
|
||||
existing = PersonIdentifier.objects.filter(
|
||||
user=request.user,
|
||||
service="signal",
|
||||
identifier=candidate,
|
||||
).first()
|
||||
if existing is None:
|
||||
PersonIdentifier.objects.create(
|
||||
user=request.user,
|
||||
person=person,
|
||||
service="signal",
|
||||
identifier=candidate,
|
||||
)
|
||||
linked_companions += 1
|
||||
continue
|
||||
if existing.person_id != person.id:
|
||||
skipped_companions += 1
|
||||
if linked_companions:
|
||||
message = (
|
||||
f"{message} Added {linked_companions} companion Signal identifier"
|
||||
f"{'' if linked_companions == 1 else 's'}."
|
||||
)
|
||||
if skipped_companions:
|
||||
message = (
|
||||
f"{message} Skipped {skipped_companions} companion identifier"
|
||||
f"{'' if skipped_companions == 1 else 's'} already linked to another person."
|
||||
)
|
||||
return render(
|
||||
request,
|
||||
self.template_name,
|
||||
@@ -1880,12 +2124,24 @@ class ComposeThread(LoginRequiredMixin, View):
|
||||
latest_ts = after_ts
|
||||
messages = []
|
||||
seed_previous = None
|
||||
session_ids = ComposeHistorySync._session_ids_for_scope(
|
||||
user=request.user,
|
||||
person=base["person"],
|
||||
service=service,
|
||||
person_identifier=base["person_identifier"],
|
||||
explicit_identifier=base["identifier"],
|
||||
)
|
||||
if base["person_identifier"] is not None:
|
||||
session, _ = ChatSession.objects.get_or_create(
|
||||
user=request.user,
|
||||
identifier=base["person_identifier"],
|
||||
)
|
||||
base_queryset = Message.objects.filter(user=request.user, session=session)
|
||||
session_ids = list({*session_ids, int(session.id)})
|
||||
if session_ids:
|
||||
base_queryset = Message.objects.filter(
|
||||
user=request.user,
|
||||
session_id__in=session_ids,
|
||||
)
|
||||
queryset = base_queryset
|
||||
if after_ts > 0:
|
||||
seed_previous = (
|
||||
@@ -1901,7 +2157,10 @@ class ComposeThread(LoginRequiredMixin, View):
|
||||
.order_by("ts")[:limit]
|
||||
)
|
||||
newest = (
|
||||
Message.objects.filter(user=request.user, session=session)
|
||||
Message.objects.filter(
|
||||
user=request.user,
|
||||
session_id__in=session_ids,
|
||||
)
|
||||
.order_by("-ts")
|
||||
.values_list("ts", flat=True)
|
||||
.first()
|
||||
@@ -1928,6 +2187,284 @@ class ComposeThread(LoginRequiredMixin, View):
|
||||
return JsonResponse(payload)
|
||||
|
||||
|
||||
class ComposeHistorySync(LoginRequiredMixin, View):
|
||||
@staticmethod
|
||||
def _session_ids_for_identifier(user, person_identifier):
|
||||
if person_identifier is None:
|
||||
return []
|
||||
return list(
|
||||
ChatSession.objects.filter(
|
||||
user=user,
|
||||
identifier=person_identifier,
|
||||
).values_list("id", flat=True)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _identifier_variants(service: str, identifier: str):
|
||||
raw = str(identifier or "").strip()
|
||||
if not raw:
|
||||
return []
|
||||
values = {raw}
|
||||
if service == "whatsapp":
|
||||
digits = re.sub(r"[^0-9]", "", raw)
|
||||
if digits:
|
||||
values.add(digits)
|
||||
values.add(f"+{digits}")
|
||||
values.add(f"{digits}@s.whatsapp.net")
|
||||
if "@" in raw:
|
||||
local = raw.split("@", 1)[0].strip()
|
||||
if local:
|
||||
values.add(local)
|
||||
return [value for value in values if value]
|
||||
|
||||
@classmethod
|
||||
def _session_ids_for_scope(
|
||||
cls,
|
||||
user,
|
||||
person,
|
||||
service: str,
|
||||
person_identifier,
|
||||
explicit_identifier: str,
|
||||
):
|
||||
identifiers = []
|
||||
if person_identifier is not None:
|
||||
identifiers.append(person_identifier)
|
||||
if person is not None:
|
||||
identifiers.extend(
|
||||
list(
|
||||
PersonIdentifier.objects.filter(
|
||||
user=user,
|
||||
person=person,
|
||||
service=service,
|
||||
)
|
||||
)
|
||||
)
|
||||
variants = cls._identifier_variants(service, explicit_identifier)
|
||||
if variants:
|
||||
identifiers.extend(
|
||||
list(
|
||||
PersonIdentifier.objects.filter(
|
||||
user=user,
|
||||
service=service,
|
||||
identifier__in=variants,
|
||||
)
|
||||
)
|
||||
)
|
||||
unique_ids = []
|
||||
seen = set()
|
||||
for row in identifiers:
|
||||
row_id = int(row.id)
|
||||
if row_id in seen:
|
||||
continue
|
||||
seen.add(row_id)
|
||||
unique_ids.append(row_id)
|
||||
if not unique_ids:
|
||||
return []
|
||||
return list(
|
||||
ChatSession.objects.filter(
|
||||
user=user,
|
||||
identifier_id__in=unique_ids,
|
||||
).values_list("id", flat=True)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _reconcile_duplicate_messages(user, session_ids):
|
||||
if not session_ids:
|
||||
return 0
|
||||
rows = list(
|
||||
Message.objects.filter(
|
||||
user=user,
|
||||
session_id__in=session_ids,
|
||||
)
|
||||
.order_by("id")
|
||||
.values("id", "session_id", "ts", "sender_uuid", "text", "custom_author")
|
||||
)
|
||||
seen = {}
|
||||
duplicate_ids = []
|
||||
for row in rows:
|
||||
dedupe_key = (
|
||||
int(row.get("session_id") or 0),
|
||||
int(row.get("ts") or 0),
|
||||
str(row.get("sender_uuid") or ""),
|
||||
str(row.get("text") or ""),
|
||||
str(row.get("custom_author") or ""),
|
||||
)
|
||||
if dedupe_key in seen:
|
||||
duplicate_ids.append(row["id"])
|
||||
continue
|
||||
seen[dedupe_key] = row["id"]
|
||||
if not duplicate_ids:
|
||||
return 0
|
||||
Message.objects.filter(user=user, id__in=duplicate_ids).delete()
|
||||
return len(duplicate_ids)
|
||||
|
||||
def post(self, request):
|
||||
service = _default_service(request.POST.get("service"))
|
||||
identifier = str(request.POST.get("identifier") or "").strip()
|
||||
person = None
|
||||
person_id = request.POST.get("person")
|
||||
if person_id:
|
||||
person = get_object_or_404(Person, id=person_id, user=request.user)
|
||||
if not identifier and person is None:
|
||||
return JsonResponse(
|
||||
{"ok": False, "message": "Missing contact identifier.", "level": "danger"}
|
||||
)
|
||||
|
||||
base = _context_base(request.user, service, identifier, person)
|
||||
if base["person_identifier"] is None:
|
||||
return JsonResponse(
|
||||
{
|
||||
"ok": False,
|
||||
"message": "No linked identifier for this contact yet.",
|
||||
"level": "warning",
|
||||
}
|
||||
)
|
||||
|
||||
session_ids = self._session_ids_for_scope(
|
||||
user=request.user,
|
||||
person=base["person"],
|
||||
service=base["service"],
|
||||
person_identifier=base["person_identifier"],
|
||||
explicit_identifier=base["identifier"],
|
||||
)
|
||||
before_count = 0
|
||||
if session_ids:
|
||||
before_count = Message.objects.filter(
|
||||
user=request.user,
|
||||
session_id__in=session_ids,
|
||||
).count()
|
||||
|
||||
runtime_result = {}
|
||||
if base["service"] == "whatsapp":
|
||||
command_id = transport.enqueue_runtime_command(
|
||||
"whatsapp",
|
||||
"force_history_sync",
|
||||
{
|
||||
"identifier": base["identifier"],
|
||||
"person_id": str(base["person"].id) if base["person"] else "",
|
||||
},
|
||||
)
|
||||
runtime_result = async_to_sync(transport.wait_runtime_command_result)(
|
||||
"whatsapp",
|
||||
command_id,
|
||||
timeout=25,
|
||||
)
|
||||
if runtime_result is None:
|
||||
return JsonResponse(
|
||||
{
|
||||
"ok": False,
|
||||
"message": (
|
||||
"History sync timed out. Runtime may still be processing; "
|
||||
"watch Runtime Debug and retry."
|
||||
),
|
||||
"level": "warning",
|
||||
}
|
||||
)
|
||||
if not runtime_result.get("ok"):
|
||||
error_text = str(runtime_result.get("error") or "history_sync_failed")
|
||||
return JsonResponse(
|
||||
{
|
||||
"ok": False,
|
||||
"message": f"History sync failed: {error_text}",
|
||||
"level": "danger",
|
||||
}
|
||||
)
|
||||
else:
|
||||
return JsonResponse(
|
||||
{
|
||||
"ok": False,
|
||||
"message": (
|
||||
f"Force history sync is only available for WhatsApp right now "
|
||||
f"(current: {base['service']})."
|
||||
),
|
||||
"level": "warning",
|
||||
}
|
||||
)
|
||||
|
||||
session_ids = self._session_ids_for_scope(
|
||||
user=request.user,
|
||||
person=base["person"],
|
||||
service=base["service"],
|
||||
person_identifier=base["person_identifier"],
|
||||
explicit_identifier=base["identifier"],
|
||||
)
|
||||
raw_after_count = 0
|
||||
if session_ids:
|
||||
raw_after_count = Message.objects.filter(
|
||||
user=request.user,
|
||||
session_id__in=session_ids,
|
||||
).count()
|
||||
dedup_removed = self._reconcile_duplicate_messages(request.user, session_ids)
|
||||
after_count = raw_after_count
|
||||
if dedup_removed > 0:
|
||||
after_count = Message.objects.filter(
|
||||
user=request.user,
|
||||
session_id__in=session_ids,
|
||||
).count()
|
||||
|
||||
imported_count = max(0, int(raw_after_count) - int(before_count))
|
||||
net_new_count = max(0, int(after_count) - int(before_count))
|
||||
delta = max(0, int(after_count) - int(before_count))
|
||||
if delta > 0:
|
||||
detail = []
|
||||
if imported_count:
|
||||
detail.append(f"imported {imported_count}")
|
||||
if dedup_removed:
|
||||
detail.append(f"reconciled {dedup_removed} duplicate(s)")
|
||||
suffix = f" ({', '.join(detail)})" if detail else ""
|
||||
return JsonResponse(
|
||||
{
|
||||
"ok": True,
|
||||
"message": f"History sync complete. Net +{net_new_count} message(s){suffix}.",
|
||||
"level": "success",
|
||||
"new_messages": net_new_count,
|
||||
"imported_messages": imported_count,
|
||||
"reconciled_duplicates": dedup_removed,
|
||||
"before": before_count,
|
||||
"after": after_count,
|
||||
"runtime_result": runtime_result,
|
||||
}
|
||||
)
|
||||
if dedup_removed > 0:
|
||||
return JsonResponse(
|
||||
{
|
||||
"ok": True,
|
||||
"message": (
|
||||
f"History sync complete. Reconciled {dedup_removed} duplicate message(s)."
|
||||
),
|
||||
"level": "success",
|
||||
"new_messages": 0,
|
||||
"imported_messages": imported_count,
|
||||
"reconciled_duplicates": dedup_removed,
|
||||
"before": before_count,
|
||||
"after": after_count,
|
||||
"runtime_result": runtime_result,
|
||||
}
|
||||
)
|
||||
return JsonResponse(
|
||||
{
|
||||
"ok": True,
|
||||
"message": (
|
||||
(
|
||||
"History sync completed, but this WhatsApp runtime session does not expose "
|
||||
"message text history yet "
|
||||
f"({str(runtime_result.get('sqlite_error') or 'no_message_history_source')}). "
|
||||
"Live incoming/outgoing messages will continue to sync."
|
||||
)
|
||||
if str(runtime_result.get("sqlite_error") or "").strip()
|
||||
else "History sync completed. No new messages were found yet; retry in a few seconds."
|
||||
),
|
||||
"level": "info",
|
||||
"new_messages": 0,
|
||||
"imported_messages": imported_count,
|
||||
"reconciled_duplicates": dedup_removed,
|
||||
"before": before_count,
|
||||
"after": after_count,
|
||||
"runtime_result": runtime_result,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class ComposeMediaBlob(LoginRequiredMixin, View):
|
||||
"""
|
||||
Serve cached media blobs for authenticated compose image previews.
|
||||
@@ -2151,13 +2688,15 @@ class ComposeQuickInsights(LoginRequiredMixin, View):
|
||||
|
||||
payload = _quick_insights_rows(conversation)
|
||||
participant_state = _participant_feedback_state_label(conversation, person)
|
||||
selected_platform_label = _service_label(base["service"])
|
||||
return JsonResponse(
|
||||
{
|
||||
"ok": True,
|
||||
"empty": False,
|
||||
"summary": {
|
||||
"person_name": person.name,
|
||||
"platform": conversation.get_platform_type_display(),
|
||||
"platform": selected_platform_label,
|
||||
"platform_scope": "All linked platforms",
|
||||
"state": participant_state
|
||||
or conversation.get_stability_state_display(),
|
||||
"stability_state": conversation.get_stability_state_display(),
|
||||
@@ -2194,6 +2733,7 @@ class ComposeQuickInsights(LoginRequiredMixin, View):
|
||||
"Each row shows current value, percent change vs previous point, and data-point count.",
|
||||
"Arrow color indicates improving or risk direction for that metric.",
|
||||
"State uses participant feedback (Withdrawing/Overextending/Balanced) when available.",
|
||||
"Values are computed from all linked platform messages for this person.",
|
||||
"Face indicator maps value range to positive, mixed, or strained climate.",
|
||||
"Use this card for fast triage; open AI Workspace for full graphs and details.",
|
||||
],
|
||||
|
||||
@@ -263,6 +263,18 @@ class WhatsAppChatsList(WhatsAppContactsList):
|
||||
|
||||
def get_queryset(self, *args, **kwargs):
|
||||
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()
|
||||
if not identifier:
|
||||
continue
|
||||
runtime_name_map[identifier] = str(item.get("name") or "").strip()
|
||||
|
||||
sessions = (
|
||||
ChatSession.objects.filter(
|
||||
user=self.request.user,
|
||||
@@ -273,8 +285,9 @@ class WhatsAppChatsList(WhatsAppContactsList):
|
||||
)
|
||||
for session in sessions:
|
||||
identifier = str(session.identifier.identifier or "").strip()
|
||||
if not identifier:
|
||||
if not identifier or identifier in seen:
|
||||
continue
|
||||
seen.add(identifier)
|
||||
latest = (
|
||||
Message.objects.filter(user=self.request.user, session=session)
|
||||
.order_by("-ts")
|
||||
@@ -284,15 +297,17 @@ class WhatsAppChatsList(WhatsAppContactsList):
|
||||
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
|
||||
or "WhatsApp Chat"
|
||||
)
|
||||
rows.append(
|
||||
{
|
||||
"identifier": identifier,
|
||||
"jid": identifier,
|
||||
"name": (
|
||||
preview
|
||||
or session.identifier.person.name
|
||||
or "WhatsApp Chat"
|
||||
),
|
||||
"name": display_name,
|
||||
"service_icon_class": _service_icon_class("whatsapp"),
|
||||
"person_name": session.identifier.person.name,
|
||||
"compose_page_url": urls["page_url"],
|
||||
@@ -304,6 +319,41 @@ class WhatsAppChatsList(WhatsAppContactsList):
|
||||
"last_ts": int(latest.ts or 0) if latest else 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 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 ""))
|
||||
urls = _compose_urls(
|
||||
"whatsapp",
|
||||
identifier,
|
||||
linked.person_id if linked else None,
|
||||
)
|
||||
rows.append(
|
||||
{
|
||||
"identifier": identifier,
|
||||
"jid": str(item.get("jid") or identifier).strip(),
|
||||
"name": str(item.get("name") or "WhatsApp Chat").strip()
|
||||
or "WhatsApp Chat",
|
||||
"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})}"
|
||||
),
|
||||
"last_ts": 0,
|
||||
}
|
||||
)
|
||||
if rows:
|
||||
rows.sort(key=lambda row: row.get("last_ts", 0), reverse=True)
|
||||
return rows
|
||||
@@ -355,8 +405,16 @@ class WhatsAppAccountAdd(SuperUserRequiredMixin, ObjectRead):
|
||||
|
||||
qr_value = str(state.get("pair_qr") or "")
|
||||
contacts = state.get("contacts") or []
|
||||
history_imported = int(state.get("history_imported_messages") or 0)
|
||||
sqlite_imported = int(state.get("history_sqlite_imported") or 0)
|
||||
sqlite_scanned = int(state.get("history_sqlite_scanned") or 0)
|
||||
on_demand_requested = bool(state.get("history_on_demand_requested"))
|
||||
on_demand_error = str(state.get("history_on_demand_error") or "").strip() or "-"
|
||||
on_demand_anchor = str(state.get("history_on_demand_anchor") or "").strip() or "-"
|
||||
history_running = bool(state.get("history_sync_running"))
|
||||
return [
|
||||
f"connected={bool(state.get('connected'))}",
|
||||
f"runtime_updated={_age('updated_at')}",
|
||||
f"runtime_seen={_age('runtime_seen_at')}",
|
||||
f"pair_requested={_age('pair_requested_at')}",
|
||||
f"qr_received={_age('qr_received_at')}",
|
||||
@@ -370,6 +428,21 @@ class WhatsAppAccountAdd(SuperUserRequiredMixin, ObjectRead):
|
||||
f"contacts_count={len(contacts) if isinstance(contacts, list) else 0}",
|
||||
f"contacts_sync_count={state.get('contacts_sync_count') or 0}",
|
||||
f"contacts_synced={_age('contacts_synced_at')}",
|
||||
f"history_sync_running={history_running}",
|
||||
f"history_started={_age('history_sync_started_at')}",
|
||||
f"history_finished={_age('history_sync_finished_at')}",
|
||||
f"history_duration_ms={state.get('history_sync_duration_ms') or 0}",
|
||||
f"history_imported_messages={history_imported}",
|
||||
f"history_sqlite_imported={sqlite_imported}",
|
||||
f"history_sqlite_scanned={sqlite_scanned}",
|
||||
f"history_sqlite_rows={state.get('history_sqlite_rows') or 0}",
|
||||
f"history_sqlite_table={state.get('history_sqlite_table') or '-'}",
|
||||
f"history_sqlite_error={state.get('history_sqlite_error') or '-'}",
|
||||
f"history_sqlite_ts={_age('history_sqlite_ts')}",
|
||||
f"history_on_demand_requested={on_demand_requested}",
|
||||
f"history_on_demand_at={_age('history_on_demand_at')}",
|
||||
f"history_on_demand_anchor={on_demand_anchor}",
|
||||
f"history_on_demand_error={on_demand_error}",
|
||||
f"pair_qr_present={bool(qr_value)}",
|
||||
f"session_db={state.get('session_db') or '-'}",
|
||||
]
|
||||
|
||||
@@ -603,6 +603,81 @@ def _resolve_person_identifier(user, person, preferred_service=None):
|
||||
return PersonIdentifier.objects.filter(user=user, person=person).first()
|
||||
|
||||
|
||||
def _send_target_options_for_person(user, person):
|
||||
rows = list(
|
||||
PersonIdentifier.objects.filter(user=user, person=person)
|
||||
.exclude(identifier="")
|
||||
.order_by("service", "identifier", "id")
|
||||
)
|
||||
if not rows:
|
||||
return {"options": [], "selected_id": ""}
|
||||
|
||||
preferred_service = _preferred_service_for_person(user, person)
|
||||
labels = {
|
||||
"signal": "Signal",
|
||||
"whatsapp": "WhatsApp",
|
||||
"instagram": "Instagram",
|
||||
"xmpp": "XMPP",
|
||||
}
|
||||
seen = set()
|
||||
options = []
|
||||
for row in rows:
|
||||
service = str(row.service or "").strip().lower()
|
||||
identifier = str(row.identifier or "").strip()
|
||||
if not service or not identifier:
|
||||
continue
|
||||
dedupe_key = (service, identifier)
|
||||
if dedupe_key in seen:
|
||||
continue
|
||||
seen.add(dedupe_key)
|
||||
options.append(
|
||||
{
|
||||
"id": str(row.id),
|
||||
"service": service,
|
||||
"service_label": labels.get(service, service.title()),
|
||||
"identifier": identifier,
|
||||
}
|
||||
)
|
||||
|
||||
if not options:
|
||||
return {"options": [], "selected_id": ""}
|
||||
|
||||
selected_id = options[0]["id"]
|
||||
if preferred_service:
|
||||
preferred = next(
|
||||
(item for item in options if item["service"] == preferred_service),
|
||||
None,
|
||||
)
|
||||
if preferred is not None:
|
||||
selected_id = preferred["id"]
|
||||
return {"options": options, "selected_id": selected_id}
|
||||
|
||||
|
||||
def _resolve_person_identifier_target(
|
||||
user,
|
||||
person,
|
||||
target_identifier_id="",
|
||||
target_service="",
|
||||
fallback_service=None,
|
||||
):
|
||||
target_id = str(target_identifier_id or "").strip()
|
||||
if target_id:
|
||||
selected = PersonIdentifier.objects.filter(
|
||||
user=user,
|
||||
person=person,
|
||||
id=target_id,
|
||||
).first()
|
||||
if selected is not None:
|
||||
return selected
|
||||
|
||||
preferred = str(target_service or "").strip().lower() or fallback_service
|
||||
return _resolve_person_identifier(
|
||||
user=user,
|
||||
person=person,
|
||||
preferred_service=preferred,
|
||||
)
|
||||
|
||||
|
||||
def _preferred_service_for_person(user, person):
|
||||
"""
|
||||
Best-effort service hint from the most recent workspace conversation.
|
||||
@@ -3314,6 +3389,14 @@ def _mitigation_panel_context(
|
||||
selected_ref = engage_form.get("source_ref") or (
|
||||
engage_options[0]["value"] if engage_options else ""
|
||||
)
|
||||
send_target_bundle = _send_target_options_for_person(plan.user, person)
|
||||
selected_target_id = str(engage_form.get("target_identifier_id") or "").strip()
|
||||
if selected_target_id and not any(
|
||||
item["id"] == selected_target_id for item in send_target_bundle["options"]
|
||||
):
|
||||
selected_target_id = ""
|
||||
if not selected_target_id:
|
||||
selected_target_id = send_target_bundle["selected_id"]
|
||||
auto_settings = auto_settings or _get_or_create_auto_settings(
|
||||
plan.user, plan.conversation
|
||||
)
|
||||
@@ -3340,7 +3423,9 @@ def _mitigation_panel_context(
|
||||
"share_target": engage_form.get("share_target") or "self",
|
||||
"framing": engage_form.get("framing") or "dont_change",
|
||||
"context_note": engage_form.get("context_note") or "",
|
||||
"target_identifier_id": selected_target_id,
|
||||
},
|
||||
"send_target_bundle": send_target_bundle,
|
||||
"send_state": _get_send_state(plan.user, person),
|
||||
"active_tab": _sanitize_active_tab(active_tab),
|
||||
"auto_settings": auto_settings,
|
||||
@@ -3463,12 +3548,15 @@ class AIWorkspacePersonWidget(LoginRequiredMixin, View):
|
||||
],
|
||||
"send_state": _get_send_state(request.user, person),
|
||||
"compose_page_url": _compose_page_url_for_person(request.user, person),
|
||||
"compose_page_base_url": reverse("compose_page"),
|
||||
"compose_widget_url": _compose_widget_url_for_person(
|
||||
request.user,
|
||||
person,
|
||||
limit=limit,
|
||||
),
|
||||
"compose_widget_base_url": reverse("compose_widget"),
|
||||
"manual_icon_class": "fa-solid fa-paper-plane",
|
||||
"send_target_bundle": _send_target_options_for_person(request.user, person),
|
||||
}
|
||||
return render(request, "mixins/wm/widget.html", context)
|
||||
|
||||
@@ -3799,6 +3887,7 @@ class AIWorkspaceRunOperation(LoginRequiredMixin, View):
|
||||
|
||||
person = get_object_or_404(Person, pk=person_id, user=request.user)
|
||||
send_state = _get_send_state(request.user, person)
|
||||
send_target_bundle = _send_target_options_for_person(request.user, person)
|
||||
conversation = _conversation_for_person(request.user, person)
|
||||
|
||||
if operation == "artifacts":
|
||||
@@ -3859,6 +3948,7 @@ class AIWorkspaceRunOperation(LoginRequiredMixin, View):
|
||||
"error": False,
|
||||
"person": person,
|
||||
"send_state": send_state,
|
||||
"send_target_bundle": send_target_bundle,
|
||||
"ai_result_id": "",
|
||||
"mitigation_notice_message": mitigation_notice_message,
|
||||
"mitigation_notice_level": mitigation_notice_level,
|
||||
@@ -3880,6 +3970,7 @@ class AIWorkspaceRunOperation(LoginRequiredMixin, View):
|
||||
"error": True,
|
||||
"person": person,
|
||||
"send_state": send_state,
|
||||
"send_target_bundle": send_target_bundle,
|
||||
"latest_plan": None,
|
||||
"latest_plan_rules": [],
|
||||
"latest_plan_games": [],
|
||||
@@ -4006,6 +4097,7 @@ class AIWorkspaceRunOperation(LoginRequiredMixin, View):
|
||||
"error": False,
|
||||
"person": person,
|
||||
"send_state": send_state,
|
||||
"send_target_bundle": send_target_bundle,
|
||||
"ai_result_id": str(ai_result.id),
|
||||
"ai_result_created_at": ai_result.created_at,
|
||||
"ai_request_status": ai_request.status,
|
||||
@@ -4035,6 +4127,7 @@ class AIWorkspaceRunOperation(LoginRequiredMixin, View):
|
||||
"error": True,
|
||||
"person": person,
|
||||
"send_state": send_state,
|
||||
"send_target_bundle": send_target_bundle,
|
||||
"latest_plan": None,
|
||||
"latest_plan_rules": [],
|
||||
"latest_plan_games": [],
|
||||
@@ -4074,10 +4167,12 @@ class AIWorkspaceSendDraft(LoginRequiredMixin, View):
|
||||
},
|
||||
)
|
||||
|
||||
identifier = _resolve_person_identifier(
|
||||
identifier = _resolve_person_identifier_target(
|
||||
request.user,
|
||||
person,
|
||||
preferred_service=_preferred_service_for_person(request.user, person),
|
||||
target_identifier_id=request.POST.get("target_identifier_id"),
|
||||
target_service=request.POST.get("target_service"),
|
||||
fallback_service=_preferred_service_for_person(request.user, person),
|
||||
)
|
||||
if identifier is None:
|
||||
return render(
|
||||
@@ -4165,10 +4260,12 @@ class AIWorkspaceQueueDraft(LoginRequiredMixin, View):
|
||||
},
|
||||
)
|
||||
|
||||
identifier = _resolve_person_identifier(
|
||||
identifier = _resolve_person_identifier_target(
|
||||
request.user,
|
||||
person,
|
||||
preferred_service=_preferred_service_for_person(request.user, person),
|
||||
target_identifier_id=request.POST.get("target_identifier_id"),
|
||||
target_service=request.POST.get("target_service"),
|
||||
fallback_service=_preferred_service_for_person(request.user, person),
|
||||
)
|
||||
if identifier is None:
|
||||
return render(
|
||||
@@ -4760,6 +4857,9 @@ class AIWorkspaceEngageShare(LoginRequiredMixin, View):
|
||||
"share_target": share_target,
|
||||
"framing": framing,
|
||||
"context_note": context_note,
|
||||
"target_identifier_id": str(
|
||||
request.POST.get("target_identifier_id") or ""
|
||||
).strip(),
|
||||
}
|
||||
active_tab = _sanitize_active_tab(
|
||||
request.POST.get("active_tab"), default="engage"
|
||||
@@ -4856,10 +4956,12 @@ class AIWorkspaceEngageShare(LoginRequiredMixin, View):
|
||||
),
|
||||
)
|
||||
|
||||
identifier = _resolve_person_identifier(
|
||||
identifier = _resolve_person_identifier_target(
|
||||
request.user,
|
||||
person,
|
||||
preferred_service=plan.conversation.platform_type,
|
||||
target_identifier_id=request.POST.get("target_identifier_id"),
|
||||
target_service=request.POST.get("target_service"),
|
||||
fallback_service=plan.conversation.platform_type,
|
||||
)
|
||||
if identifier is None:
|
||||
return render(
|
||||
@@ -4955,10 +5057,12 @@ class AIWorkspaceEngageShare(LoginRequiredMixin, View):
|
||||
return response
|
||||
|
||||
if action == "queue":
|
||||
identifier = _resolve_person_identifier(
|
||||
identifier = _resolve_person_identifier_target(
|
||||
request.user,
|
||||
person,
|
||||
preferred_service=plan.conversation.platform_type,
|
||||
target_identifier_id=request.POST.get("target_identifier_id"),
|
||||
target_service=request.POST.get("target_service"),
|
||||
fallback_service=plan.conversation.platform_type,
|
||||
)
|
||||
if identifier is None:
|
||||
return render(
|
||||
|
||||
Reference in New Issue
Block a user