Files
GIA/core/views/signal.py

419 lines
15 KiB
Python

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