from urllib.parse import urlencode import orjson import requests from django.conf import settings from django.contrib import messages from django.db.models import Q from django.shortcuts import render from django.urls import reverse from django.views import View from mixins.views import ObjectList, ObjectRead from core.clients import transport from core.models import Chat, PersonIdentifier, PlatformChatLink from core.presence import get_settings as get_availability_settings from core.presence import latest_state_for_people from core.views.manage.permissions import SuperUserRequiredMixin def _safe_json_list(text_value): try: payload = orjson.loads(text_value) except orjson.JSONDecodeError: return [] return payload if isinstance(payload, list) else [] def _sanitize_signal_rows(rows): safe_rows = [] for row in rows: if not isinstance(row, dict): continue safe_row = {} for key, value in row.items(): if isinstance(key, str) and len(key) <= 100: if isinstance(value, (str, int, float, bool)) or value is None: safe_row[key] = value safe_rows.append(safe_row) return safe_rows class CustomObjectRead(ObjectRead): def post(self, request, *args, **kwargs): self.request = request return super().get(request, *args, **kwargs) class Signal(SuperUserRequiredMixin, View): template_name = "pages/signal.html" service = "signal" page_title = "Signal" accounts_url_name = "signal_accounts" def get(self, request): return render( request, self.template_name, { "service": self.service, "service_label": self.page_title, "accounts_url_name": self.accounts_url_name, }, ) class SignalAccounts(SuperUserRequiredMixin, ObjectList): list_template = "partials/signal-accounts.html" service = "signal" context_object_name_singular = "Signal Account" context_object_name = "Signal Accounts" list_url_name = "signal_accounts" list_url_args = ["type"] def _normalize_accounts(self, rows): out = [] for item in rows or []: if isinstance(item, dict): value = ( item.get("number") or item.get("id") or item.get("jid") or item.get("account") ) if value: out.append(str(value)) elif item: out.append(str(item)) return out def _service_context(self, service, label, add_url_name, show_contact_actions): return { "service": service, "service_label": label, "account_add_url_name": add_url_name, "account_add_type": "modal", "account_add_target": "#modals-here", "account_add_swap": "innerHTML", "account_unlink_url_name": "signal_account_unlink", "account_unlink_label": "Relink", "show_contact_actions": show_contact_actions, "contacts_url_name": f"{service}_contacts", "chats_url_name": f"{service}_chats", "endpoint_base": str( getattr(settings, "SIGNAL_HTTP_URL", "http://signal:8080") ).rstrip("/") if service == "signal" else "", "service_warning": transport.get_service_warning(service), } def get_queryset(self, **kwargs): self.extra_context = self._service_context( service="signal", label="Signal", add_url_name="signal_account_add", show_contact_actions=True, ) return self._normalize_accounts(transport.list_accounts("signal")) class SignalAccountUnlink(SuperUserRequiredMixin, View): def post(self, request, *args, **kwargs): return self.delete(request, *args, **kwargs) def delete(self, request, *args, **kwargs): account = str(kwargs.get("account") or "").strip() if account: ok = transport.unlink_account("signal", account) if ok: messages.success( request, ( "Signal account unlinked. Next step: enter a device name under " "'Add account', submit, then scan the new QR code." ), ) else: messages.error( request, "Signal relink failed to clear current device state. Try relink again.", ) else: messages.warning(request, "No Signal account selected to relink.") rows = [] for item in transport.list_accounts("signal"): if isinstance(item, dict): value = ( item.get("number") or item.get("id") or item.get("jid") or item.get("account") ) if value: rows.append(str(value)) elif item: rows.append(str(item)) context = { "service": "signal", "service_label": "Signal", "account_add_url_name": "signal_account_add", "account_add_type": "modal", "account_add_target": "#modals-here", "account_add_swap": "innerHTML", "account_unlink_url_name": "signal_account_unlink", "account_unlink_label": "Relink", "show_contact_actions": True, "contacts_url_name": "signal_contacts", "chats_url_name": "signal_chats", "endpoint_base": str( getattr(settings, "SIGNAL_HTTP_URL", "http://signal:8080") ).rstrip("/"), "service_warning": transport.get_service_warning("signal"), "object_list": rows, "list_url": reverse("signal_accounts", kwargs={"type": kwargs["type"]}), "type": kwargs["type"], "context_object_name_singular": "Signal Account", "context_object_name": "Signal Accounts", } return render(request, "partials/signal-accounts.html", context) class SignalContactsList(SuperUserRequiredMixin, ObjectList): list_template = "partials/signal-contacts-list.html" context_object_name_singular = "Signal Contact" context_object_name = "Signal Contacts" list_url_name = "signal_contacts" list_url_args = ["type", "pk"] def get_queryset(self, *args, **kwargs): base = getattr(settings, "SIGNAL_HTTP_URL", "http://signal:8080").rstrip("/") try: response = requests.get( f"{base}/v1/identities/{self.kwargs['pk']}", timeout=15 ) response.raise_for_status() identities = _sanitize_signal_rows(response.json() or []) except requests.RequestException: identities = [] except ValueError: identities = [] try: response = requests.get( f"{base}/v1/contacts/{self.kwargs['pk']}", timeout=15 ) response.raise_for_status() contacts = _sanitize_signal_rows(response.json() or []) except requests.RequestException: contacts = [] except ValueError: contacts = [] # add identities to contacts for contact in contacts: for identity in identities: if contact["number"] == identity["number"]: contact["identity"] = identity obj = { # "identity": identity, "contacts": contacts, } self.extra_context = {"pretty": list(obj.keys())} return obj class SignalChatsList(SuperUserRequiredMixin, ObjectList): list_template = "partials/signal-chats-list.html" context_object_name_singular = "Signal Chat" context_object_name = "Signal Chats" list_url_name = "signal_chats" list_url_args = ["type", "pk"] def get_queryset(self, *args, **kwargs): pk = self.kwargs.get("pk", "") availability_settings = get_availability_settings(self.request.user) show_availability = bool( availability_settings.enabled and availability_settings.show_in_groups ) chats = list( Chat.objects.filter( Q(account=pk) | Q(account__isnull=True) | Q(account="") ).order_by("-id")[:1000] ) if not chats: chats = list(Chat.objects.all().order_by("-id")[:1000]) rows = [] for chat in chats: identifier_candidates = [ str(chat.source_uuid or "").strip(), str(chat.source_number or "").strip(), ] identifier_candidates = [value for value in identifier_candidates if value] person_identifier = None if identifier_candidates: person_identifier = ( PersonIdentifier.objects.filter( user=self.request.user, service="signal", identifier__in=identifier_candidates, ) .select_related("person") .first() ) identifier_value = ( person_identifier.identifier if person_identifier else "" ) or (chat.source_uuid or chat.source_number or "") service = "signal" compose_page_url = "" compose_widget_url = "" if identifier_value: query = f"service={service}&identifier={identifier_value}" if person_identifier: query += f"&person={person_identifier.person_id}" compose_page_url = f"{reverse('compose_page')}?{query}" compose_widget_url = f"{reverse('compose_widget')}?{query}" if person_identifier: ai_url = ( f"{reverse('ai_workspace')}?person={person_identifier.person_id}" ) else: ai_url = reverse("ai_workspace") rows.append( { "chat": chat, "compose_page_url": compose_page_url, "compose_widget_url": compose_widget_url, "ai_url": ai_url, "person_name": ( person_identifier.person.name if person_identifier else "" ), "person_id": ( str(person_identifier.person_id) if person_identifier else "" ), "manual_icon_class": "fa-solid fa-paper-plane", "can_compose": bool(compose_page_url), "match_url": ( f"{reverse('compose_contact_match')}?" f"{urlencode({'service': 'signal', 'identifier': identifier_value})}" if identifier_value else reverse("compose_contact_match") ), } ) for link in PlatformChatLink.objects.filter( user=self.request.user, service="signal", is_group=True, ): group_id = str(link.chat_identifier or "").strip() if not group_id: continue rows.append( { "chat": None, "compose_page_url": "", "compose_widget_url": "", "ai_url": reverse("ai_workspace"), "person_name": "", "manual_icon_class": "fa-solid fa-users", "can_compose": False, "match_url": "", "is_group": True, "name": link.chat_name or group_id, "identifier": group_id, } ) if show_availability: person_ids = [ str(item.get("person_id") or "").strip() for item in rows if str(item.get("person_id") or "").strip() ] person_ids = [pid for pid in person_ids if pid] state_map = latest_state_for_people( user=self.request.user, person_ids=person_ids, service="signal", ) for row in rows: pid = str(row.get("person_id") or "").strip() if pid and pid in state_map: state_row = state_map.get(pid) or {} row["availability_state"] = str(state_row.get("state") or "unknown") row["availability_label"] = ( f"{str(state_row.get('state') or 'unknown').title()} " f"({float(state_row.get('confidence') or 0.0):.2f})" ) signal_person_ids = list( PersonIdentifier.objects.filter( user=self.request.user, service="signal", ) .exclude(person_id__isnull=True) .values_list("person_id", flat=True) .distinct() ) group_states = latest_state_for_people( user=self.request.user, person_ids=[str(pid) for pid in signal_person_ids if str(pid)], service="signal", ) aggregate_counts = {"available": 0, "fading": 0} for state_row in group_states.values(): state_text = str((state_row or {}).get("state") or "").strip().lower() if state_text in aggregate_counts: aggregate_counts[state_text] += 1 aggregate_label = ( f"{aggregate_counts['available']} available ยท {aggregate_counts['fading']} fading" if (aggregate_counts["available"] or aggregate_counts["fading"]) else "" ) if aggregate_label: for row in rows: if row.get("is_group"): row["availability_label"] = aggregate_label return rows class SignalMessagesList(SuperUserRequiredMixin, ObjectList): ... class SignalAccountAdd(SuperUserRequiredMixin, CustomObjectRead): detail_template = "partials/signal-account-add.html" service = "signal" context_object_name_singular = "Add Account" context_object_name = "Add Account" detail_url_name = "signal_account_add" detail_url_args = ["type", "device"] page_title = None def get_object(self, **kwargs): form_args = self.request.POST.dict() device_name = form_args["device"] image_bytes = transport.get_link_qr(self.service, device_name) base64_image = transport.image_bytes_to_base64(image_bytes) return base64_image