Continue AI features and improve protocol support
This commit is contained in:
369
core/views/compose.py
Normal file
369
core/views/compose.py
Normal 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
|
||||
Reference in New Issue
Block a user