Continue AI features and improve protocol support

This commit is contained in:
2026-02-15 16:57:32 +00:00
parent 2d3b8fdac6
commit 85e97e895d
62 changed files with 5472 additions and 441 deletions

View File

@@ -2,10 +2,8 @@ import logging
# import stripe
from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import JsonResponse
from django.shortcuts import redirect, render
from django.urls import reverse, reverse_lazy
from django.shortcuts import render
from django.urls import reverse_lazy
from django.views import View
from django.views.generic.edit import CreateView

369
core/views/compose.py Normal file
View File

@@ -0,0 +1,369 @@
from __future__ import annotations
import hashlib
import time
from datetime import datetime, timezone as dt_timezone
from urllib.parse import urlencode
from asgiref.sync import async_to_sync
from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import HttpResponseBadRequest, JsonResponse
from django.shortcuts import get_object_or_404, render
from django.urls import reverse
from django.utils import timezone as dj_timezone
from django.views import View
from core.clients import transport
from core.models import ChatSession, Message, Person, PersonIdentifier
def _default_service(service: str | None) -> str:
value = str(service or "").strip().lower()
if value in {"signal", "whatsapp", "instagram", "xmpp"}:
return value
return "signal"
def _safe_limit(raw) -> int:
try:
value = int(raw or 40)
except (TypeError, ValueError):
value = 40
return max(10, min(value, 200))
def _safe_after_ts(raw) -> int:
try:
value = int(raw or 0)
except (TypeError, ValueError):
value = 0
return max(0, value)
def _format_ts_label(ts_value: int) -> str:
try:
as_dt = datetime.fromtimestamp(int(ts_value) / 1000, tz=dt_timezone.utc)
return dj_timezone.localtime(as_dt).strftime("%H:%M")
except Exception:
return str(ts_value or "")
def _is_outgoing(msg: Message) -> bool:
return str(msg.custom_author or "").upper() in {"USER", "BOT"}
def _serialize_message(msg: Message) -> dict:
author = str(msg.custom_author or "").strip()
return {
"id": str(msg.id),
"ts": int(msg.ts or 0),
"display_ts": _format_ts_label(int(msg.ts or 0)),
"text": str(msg.text or ""),
"author": author,
"outgoing": _is_outgoing(msg),
}
def _context_base(user, service, identifier, person):
person_identifier = None
if person is not None:
person_identifier = (
PersonIdentifier.objects.filter(
user=user,
person=person,
service=service,
).first()
or PersonIdentifier.objects.filter(user=user, person=person).first()
)
if person_identifier is None and identifier:
person_identifier = PersonIdentifier.objects.filter(
user=user,
service=service,
identifier=identifier,
).first()
if person_identifier:
service = person_identifier.service
identifier = person_identifier.identifier
person = person_identifier.person
return {
"person_identifier": person_identifier,
"service": service,
"identifier": identifier,
"person": person,
}
def _compose_urls(service, identifier, person_id):
query = {"service": service, "identifier": identifier}
if person_id:
query["person"] = str(person_id)
payload = urlencode(query)
return {
"page_url": f"{reverse('compose_page')}?{payload}",
"widget_url": f"{reverse('compose_widget')}?{payload}",
}
def _load_messages(user, person_identifier, limit):
if person_identifier is None:
return {"session": None, "messages": []}
session, _ = ChatSession.objects.get_or_create(
user=user,
identifier=person_identifier,
)
messages = list(
Message.objects.filter(user=user, session=session)
.select_related("session", "session__identifier", "session__identifier__person")
.order_by("-ts")[:limit]
)
messages.reverse()
return {"session": session, "messages": messages}
def _panel_context(
request,
service: str,
identifier: str,
person: Person | None,
render_mode: str,
notice: str = "",
level: str = "success",
):
base = _context_base(request.user, service, identifier, person)
limit = _safe_limit(request.GET.get("limit") or request.POST.get("limit"))
session_bundle = _load_messages(request.user, base["person_identifier"], limit)
last_ts = 0
if session_bundle["messages"]:
last_ts = int(session_bundle["messages"][-1].ts or 0)
urls = _compose_urls(
base["service"],
base["identifier"],
base["person"].id if base["person"] else None,
)
unique_raw = f"{base['service']}|{base['identifier']}|{request.user.id}"
unique = hashlib.sha1(unique_raw.encode("utf-8")).hexdigest()[:12]
return {
"service": base["service"],
"identifier": base["identifier"],
"person": base["person"],
"person_identifier": base["person_identifier"],
"session": session_bundle["session"],
"messages": session_bundle["messages"],
"serialized_messages": [
_serialize_message(msg) for msg in session_bundle["messages"]
],
"last_ts": last_ts,
"limit": limit,
"notice_message": notice,
"notice_level": level,
"render_mode": render_mode,
"compose_page_url": urls["page_url"],
"compose_widget_url": urls["widget_url"],
"ai_workspace_url": (
f"{reverse('ai_workspace')}?person={base['person'].id}"
if base["person"]
else reverse("ai_workspace")
),
"manual_icon_class": "fa-solid fa-paper-plane",
"panel_id": f"compose-panel-{unique}",
}
class ComposeContactsDropdown(LoginRequiredMixin, View):
def get(self, request):
rows = list(
PersonIdentifier.objects.filter(user=request.user)
.select_related("person")
.order_by("person__name", "service", "identifier")
)
items = []
for row in rows:
urls = _compose_urls(row.service, row.identifier, row.person_id)
items.append(
{
"person_name": row.person.name,
"service": row.service,
"identifier": row.identifier,
"compose_url": urls["page_url"],
}
)
return render(
request,
"partials/nav-contacts-dropdown.html",
{
"items": items,
"manual_icon_class": "fa-solid fa-paper-plane",
},
)
class ComposePage(LoginRequiredMixin, View):
template_name = "pages/compose.html"
def get(self, request):
service = _default_service(request.GET.get("service"))
identifier = str(request.GET.get("identifier") or "").strip()
person = None
person_id = request.GET.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 HttpResponseBadRequest("Missing contact identifier.")
context = _panel_context(
request=request,
service=service,
identifier=identifier,
person=person,
render_mode="page",
)
return render(request, self.template_name, context)
class ComposeWidget(LoginRequiredMixin, View):
def get(self, request):
service = _default_service(request.GET.get("service"))
identifier = str(request.GET.get("identifier") or "").strip()
person = None
person_id = request.GET.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 HttpResponseBadRequest("Missing contact identifier.")
panel_context = _panel_context(
request=request,
service=service,
identifier=identifier,
person=person,
render_mode="widget",
)
title_name = (
panel_context["person"].name
if panel_context["person"] is not None
else panel_context["identifier"]
)
context = {
"title": f"Manual Chat: {title_name}",
"unique": f"compose-{panel_context['panel_id']}",
"window_content": "partials/compose-panel.html",
"widget_options": 'gs-w="6" gs-h="12" gs-x="0" gs-y="0" gs-min-w="4"',
**panel_context,
}
return render(request, "mixins/wm/widget.html", context)
class ComposeThread(LoginRequiredMixin, View):
def get(self, request):
service = _default_service(request.GET.get("service"))
identifier = str(request.GET.get("identifier") or "").strip()
person = None
person_id = request.GET.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 HttpResponseBadRequest("Missing contact identifier.")
limit = _safe_limit(request.GET.get("limit") or 60)
after_ts = _safe_after_ts(request.GET.get("after_ts"))
base = _context_base(request.user, service, identifier, person)
latest_ts = after_ts
messages = []
if base["person_identifier"] is not None:
session, _ = ChatSession.objects.get_or_create(
user=request.user,
identifier=base["person_identifier"],
)
queryset = Message.objects.filter(user=request.user, session=session)
if after_ts > 0:
queryset = queryset.filter(ts__gt=after_ts)
messages = list(
queryset.select_related(
"session",
"session__identifier",
"session__identifier__person",
)
.order_by("ts")[:limit]
)
newest = (
Message.objects.filter(user=request.user, session=session)
.order_by("-ts")
.values_list("ts", flat=True)
.first()
)
if newest:
latest_ts = max(latest_ts, int(newest))
payload = {
"messages": [_serialize_message(msg) for msg in messages],
"last_ts": latest_ts,
}
return JsonResponse(payload)
class ComposeSend(LoginRequiredMixin, View):
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)
render_mode = str(request.POST.get("render_mode") or "page").strip().lower()
if render_mode not in {"page", "widget"}:
render_mode = "page"
if not identifier and person is None:
return HttpResponseBadRequest("Missing contact identifier.")
text = str(request.POST.get("text") or "").strip()
if not text:
return render(
request,
"partials/compose-send-status.html",
{"notice_message": "Message is empty.", "notice_level": "danger"},
)
base = _context_base(request.user, service, identifier, person)
ts = async_to_sync(transport.send_message_raw)(
base["service"],
base["identifier"],
text=text,
attachments=[],
)
if not ts:
return render(
request,
"partials/compose-send-status.html",
{
"notice_message": "Send failed. Check service account state.",
"notice_level": "danger",
},
)
if base["person_identifier"] is not None:
session, _ = ChatSession.objects.get_or_create(
user=request.user,
identifier=base["person_identifier"],
)
Message.objects.create(
user=request.user,
session=session,
sender_uuid="",
text=text,
ts=int(ts) if str(ts).isdigit() else int(time.time() * 1000),
delivered_ts=int(ts) if str(ts).isdigit() else None,
custom_author="USER",
)
response = render(
request,
"partials/compose-send-status.html",
{"notice_message": "Sent.", "notice_level": "success"},
)
response["HX-Trigger"] = "composeMessageSent"
return response

29
core/views/instagram.py Normal file
View File

@@ -0,0 +1,29 @@
from core.clients import transport
from core.views.signal import Signal, SignalAccountAdd, SignalAccounts
class Instagram(Signal):
service = "instagram"
page_title = "Instagram"
accounts_url_name = "instagram_accounts"
class InstagramAccounts(SignalAccounts):
service = "instagram"
context_object_name_singular = "Instagram Account"
context_object_name = "Instagram Accounts"
list_url_name = "instagram_accounts"
def get_queryset(self, **kwargs):
self.extra_context = self._service_context(
service="instagram",
label="Instagram",
add_url_name="instagram_account_add",
show_contact_actions=False,
)
return self._normalize_accounts(transport.list_accounts("instagram"))
class InstagramAccountAdd(SignalAccountAdd):
service = "instagram"
detail_url_name = "instagram_account_add"

View File

@@ -2,11 +2,11 @@ from asgiref.sync import async_to_sync
from django.contrib.auth.mixins import LoginRequiredMixin
from django.db import transaction
from django.http import HttpResponse
from django.utils import timezone as dj_timezone
from mixins.views import ObjectCreate, ObjectDelete, ObjectList, ObjectUpdate
from rest_framework import status
from rest_framework.views import APIView
from core.clients import signalapi
from core.forms import QueueForm
from core.models import Message, QueuedMessage
from core.util import logs
@@ -28,22 +28,18 @@ class AcceptMessageAPI(LoginRequiredMixin, APIView):
except QueuedMessage.DoesNotExist:
return HttpResponse(status=status.HTTP_404_NOT_FOUND)
if queued.session.identifier.service != "signal":
log.warning(
"Queue accept failed: unsupported service '%s' for queued message %s",
queued.session.identifier.service,
queued.id,
)
return HttpResponse(status=status.HTTP_400_BAD_REQUEST)
ts = async_to_sync(signalapi.send_message_raw)(
queued.session.identifier.identifier,
ts = async_to_sync(queued.session.identifier.send)(
queued.text or "",
[],
)
if not ts:
log.error("Queue accept send failed for queued message %s", queued.id)
return HttpResponse(status=status.HTTP_502_BAD_GATEWAY)
sent_ts = (
int(ts)
if (ts is not None and not isinstance(ts, bool))
else int(dj_timezone.now().timestamp() * 1000)
)
with transaction.atomic():
Message.objects.create(
@@ -51,7 +47,9 @@ class AcceptMessageAPI(LoginRequiredMixin, APIView):
session=queued.session,
custom_author=queued.custom_author or "BOT",
text=queued.text,
ts=ts,
ts=sent_ts,
delivered_ts=sent_ts,
read_source_service=queued.session.identifier.service,
)
queued.delete()

View File

@@ -1,12 +1,13 @@
import base64
import orjson
import requests
from django.conf import settings
from django.shortcuts import render
from django.urls import reverse
from django.views import View
from mixins.views import ObjectList, ObjectRead
from core.models import Chat
from core.clients import transport
from core.models import Chat, PersonIdentifier
from core.views.manage.permissions import SuperUserRequiredMixin
@@ -18,13 +19,25 @@ class CustomObjectRead(ObjectRead):
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)
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"
@@ -32,13 +45,44 @@ class SignalAccounts(SuperUserRequiredMixin, ObjectList):
list_url_name = "signal_accounts"
list_url_args = ["type"]
def get_queryset(self, **kwargs):
# url = signal:8080/v1/accounts
url = f"http://signal:8080/v1/accounts"
response = requests.get(url)
accounts = orjson.loads(response.text)
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
return accounts
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,
"show_contact_actions": show_contact_actions,
"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 SignalContactsList(SuperUserRequiredMixin, ObjectList):
@@ -55,13 +99,16 @@ class SignalContactsList(SuperUserRequiredMixin, ObjectList):
# /v1/configuration/{number}/settings
# /v1/identities/{number}
# /v1/contacts/{number}
# response = requests.get(f"http://signal:8080/v1/configuration/{self.kwargs['pk']}/settings")
# response = requests.get(
# f"http://signal:8080/v1/configuration/{self.kwargs['pk']}/settings"
# )
# config = orjson.loads(response.text)
response = requests.get(f"http://signal:8080/v1/identities/{self.kwargs['pk']}")
base = getattr(settings, "SIGNAL_HTTP_URL", "http://signal:8080").rstrip("/")
response = requests.get(f"{base}/v1/identities/{self.kwargs['pk']}")
identities = orjson.loads(response.text)
response = requests.get(f"http://signal:8080/v1/contacts/{self.kwargs['pk']}")
response = requests.get(f"{base}/v1/contacts/{self.kwargs['pk']}")
contacts = orjson.loads(response.text)
# add identities to contacts
@@ -90,8 +137,59 @@ class SignalChatsList(SuperUserRequiredMixin, ObjectList):
def get_queryset(self, *args, **kwargs):
pk = self.kwargs.get("pk", "")
object_list = Chat.objects.filter(account=pk)
return object_list
chats = list(Chat.objects.filter(account=pk))
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 ""
),
"manual_icon_class": "fa-solid fa-paper-plane",
"can_compose": bool(compose_page_url),
}
)
return rows
class SignalMessagesList(SuperUserRequiredMixin, ObjectList):
@@ -100,6 +198,7 @@ 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"
@@ -112,9 +211,7 @@ class SignalAccountAdd(SuperUserRequiredMixin, CustomObjectRead):
def get_object(self, **kwargs):
form_args = self.request.POST.dict()
device_name = form_args["device"]
url = f"http://signal:8080/v1/qrcodelink?device_name={device_name}"
response = requests.get(url)
image_bytes = response.content
base64_image = base64.b64encode(image_bytes).decode("utf-8")
image_bytes = transport.get_link_qr(self.service, device_name)
base64_image = transport.image_bytes_to_base64(image_bytes)
return base64_image

29
core/views/whatsapp.py Normal file
View File

@@ -0,0 +1,29 @@
from core.clients import transport
from core.views.signal import Signal, SignalAccountAdd, SignalAccounts
class WhatsApp(Signal):
service = "whatsapp"
page_title = "WhatsApp"
accounts_url_name = "whatsapp_accounts"
class WhatsAppAccounts(SignalAccounts):
service = "whatsapp"
context_object_name_singular = "WhatsApp Account"
context_object_name = "WhatsApp Accounts"
list_url_name = "whatsapp_accounts"
def get_queryset(self, **kwargs):
self.extra_context = self._service_context(
service="whatsapp",
label="WhatsApp",
add_url_name="whatsapp_account_add",
show_contact_actions=False,
)
return self._normalize_accounts(transport.list_accounts("whatsapp"))
class WhatsAppAccountAdd(SignalAccountAdd):
service = "whatsapp"
detail_url_name = "whatsapp_account_add"

File diff suppressed because it is too large Load Diff