Tightly integrate WhatsApp selectors into existing UIs

This commit is contained in:
2026-02-16 10:51:57 +00:00
parent a38339c809
commit 15af8af6b2
19 changed files with 2846 additions and 156 deletions

View File

@@ -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.",
],

View File

@@ -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 '-'}",
]

View File

@@ -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(