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 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 from mixins.views import ObjectList, ObjectRead 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): pass 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