Implement attachment view
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime, timezone as dt_timezone
|
||||
from urllib.parse import urlencode
|
||||
from urllib.parse import urlencode, urlparse
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
@@ -27,11 +28,35 @@ from core.models import (
|
||||
Person,
|
||||
PersonIdentifier,
|
||||
)
|
||||
from core.realtime.typing_state import get_person_typing_state
|
||||
from core.views.workspace import _build_engage_payload, _parse_draft_options
|
||||
|
||||
COMPOSE_WS_TOKEN_SALT = "compose-ws"
|
||||
COMPOSE_ENGAGE_TOKEN_SALT = "compose-engage"
|
||||
COMPOSE_AI_CACHE_TTL = 60 * 30
|
||||
URL_PATTERN = re.compile(r"https?://[^\s<>'\"\\]+")
|
||||
IMAGE_EXTENSIONS = (
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".gif",
|
||||
".webp",
|
||||
".bmp",
|
||||
".avif",
|
||||
".svg",
|
||||
)
|
||||
|
||||
|
||||
def _uniq_ordered(values):
|
||||
seen = set()
|
||||
output = []
|
||||
for value in values:
|
||||
cleaned = _clean_url(value)
|
||||
if not cleaned or cleaned in seen:
|
||||
continue
|
||||
seen.add(cleaned)
|
||||
output.append(cleaned)
|
||||
return output
|
||||
|
||||
|
||||
def _default_service(service: str | None) -> str:
|
||||
@@ -69,13 +94,75 @@ def _is_outgoing(msg: Message) -> bool:
|
||||
return str(msg.custom_author or "").upper() in {"USER", "BOT"}
|
||||
|
||||
|
||||
def _clean_url(candidate: str) -> str:
|
||||
return str(candidate or "").strip().rstrip(".,);:!?\"'")
|
||||
|
||||
|
||||
def _extract_urls(text_value: str) -> list[str]:
|
||||
found = []
|
||||
for match in URL_PATTERN.findall(str(text_value or "")):
|
||||
cleaned = _clean_url(match)
|
||||
if cleaned and cleaned not in found:
|
||||
found.append(cleaned)
|
||||
return found
|
||||
|
||||
|
||||
def _is_url_only_text(text_value: str) -> bool:
|
||||
lines = [line.strip() for line in str(text_value or "").splitlines() if line.strip()]
|
||||
if not lines:
|
||||
return False
|
||||
return all(bool(URL_PATTERN.fullmatch(line)) for line in lines)
|
||||
|
||||
|
||||
def _looks_like_image_url(url_value: str) -> bool:
|
||||
if not url_value:
|
||||
return False
|
||||
parsed = urlparse(url_value)
|
||||
path = str(parsed.path or "").lower()
|
||||
return path.endswith(IMAGE_EXTENSIONS)
|
||||
|
||||
|
||||
def _image_url_from_text(text_value: str) -> str:
|
||||
urls = _image_urls_from_text(text_value)
|
||||
return urls[0] if urls else ""
|
||||
|
||||
|
||||
def _image_urls_from_text(text_value: str) -> list[str]:
|
||||
urls = _uniq_ordered(_extract_urls(text_value))
|
||||
if not urls:
|
||||
return []
|
||||
|
||||
confident = [url for url in urls if _looks_like_image_url(url)]
|
||||
if confident:
|
||||
return confident
|
||||
|
||||
# Fallback: some XMPP upload URLs have no file extension.
|
||||
if _is_url_only_text(text_value):
|
||||
return urls
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def _serialize_message(msg: Message) -> dict:
|
||||
text_value = str(msg.text or "")
|
||||
image_urls = _image_urls_from_text(text_value)
|
||||
image_url = image_urls[0] if image_urls else ""
|
||||
hide_text = bool(
|
||||
image_urls
|
||||
and _is_url_only_text(text_value)
|
||||
and all(_looks_like_image_url(url) for url in image_urls)
|
||||
)
|
||||
display_text = text_value if text_value.strip() else ("(no text)" if not image_url else "")
|
||||
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 ""),
|
||||
"text": text_value,
|
||||
"display_text": display_text,
|
||||
"image_url": image_url,
|
||||
"image_urls": image_urls,
|
||||
"hide_text": hide_text,
|
||||
"author": author,
|
||||
"outgoing": _is_outgoing(msg),
|
||||
}
|
||||
@@ -400,6 +487,10 @@ def _panel_context(
|
||||
|
||||
unique_raw = f"{base['service']}|{base['identifier']}|{request.user.id}"
|
||||
unique = hashlib.sha1(unique_raw.encode("utf-8")).hexdigest()[:12]
|
||||
typing_state = get_person_typing_state(
|
||||
user_id=request.user.id,
|
||||
person_id=base["person"].id if base["person"] else None,
|
||||
)
|
||||
|
||||
return {
|
||||
"service": base["service"],
|
||||
@@ -430,6 +521,7 @@ def _panel_context(
|
||||
),
|
||||
"manual_icon_class": "fa-solid fa-paper-plane",
|
||||
"panel_id": f"compose-panel-{unique}",
|
||||
"typing_state_json": json.dumps(typing_state),
|
||||
}
|
||||
|
||||
|
||||
@@ -560,6 +652,10 @@ class ComposeThread(LoginRequiredMixin, View):
|
||||
payload = {
|
||||
"messages": [_serialize_message(msg) for msg in messages],
|
||||
"last_ts": latest_ts,
|
||||
"typing": get_person_typing_state(
|
||||
user_id=request.user.id,
|
||||
person_id=base["person"].id if base["person"] else None,
|
||||
),
|
||||
}
|
||||
return JsonResponse(payload)
|
||||
|
||||
@@ -891,6 +987,28 @@ class ComposeEngageSend(LoginRequiredMixin, View):
|
||||
|
||||
|
||||
class ComposeSend(LoginRequiredMixin, View):
|
||||
@staticmethod
|
||||
def _response(request, *, ok, message="", level="info"):
|
||||
response = render(
|
||||
request,
|
||||
"partials/compose-send-status.html",
|
||||
{
|
||||
"notice_message": message,
|
||||
"notice_level": level,
|
||||
},
|
||||
)
|
||||
trigger_payload = {
|
||||
"composeSendResult": {
|
||||
"ok": bool(ok),
|
||||
"message": str(message or ""),
|
||||
"level": str(level or "info"),
|
||||
}
|
||||
}
|
||||
if ok:
|
||||
trigger_payload["composeMessageSent"] = True
|
||||
response["HX-Trigger"] = json.dumps(trigger_payload)
|
||||
return response
|
||||
|
||||
def post(self, request):
|
||||
service = _default_service(request.POST.get("service"))
|
||||
identifier = str(request.POST.get("identifier") or "").strip()
|
||||
@@ -908,21 +1026,20 @@ class ComposeSend(LoginRequiredMixin, View):
|
||||
failsafe_arm = str(request.POST.get("failsafe_arm") or "").strip()
|
||||
failsafe_confirm = str(request.POST.get("failsafe_confirm") or "").strip()
|
||||
if failsafe_arm != "1" or failsafe_confirm != "1":
|
||||
return render(
|
||||
return self._response(
|
||||
request,
|
||||
"partials/compose-send-status.html",
|
||||
{
|
||||
"notice_message": "Enable send confirmation before sending.",
|
||||
"notice_level": "warning",
|
||||
},
|
||||
ok=False,
|
||||
message="Enable send confirmation before sending.",
|
||||
level="warning",
|
||||
)
|
||||
|
||||
text = str(request.POST.get("text") or "").strip()
|
||||
if not text:
|
||||
return render(
|
||||
return self._response(
|
||||
request,
|
||||
"partials/compose-send-status.html",
|
||||
{"notice_message": "Message is empty.", "notice_level": "danger"},
|
||||
ok=False,
|
||||
message="Message is empty.",
|
||||
level="danger",
|
||||
)
|
||||
|
||||
base = _context_base(request.user, service, identifier, person)
|
||||
@@ -933,13 +1050,11 @@ class ComposeSend(LoginRequiredMixin, View):
|
||||
attachments=[],
|
||||
)
|
||||
if not ts:
|
||||
return render(
|
||||
return self._response(
|
||||
request,
|
||||
"partials/compose-send-status.html",
|
||||
{
|
||||
"notice_message": "Send failed. Check service account state.",
|
||||
"notice_level": "danger",
|
||||
},
|
||||
ok=False,
|
||||
message="Send failed. Check service account state.",
|
||||
level="danger",
|
||||
)
|
||||
|
||||
if base["person_identifier"] is not None:
|
||||
@@ -957,10 +1072,4 @@ class ComposeSend(LoginRequiredMixin, View):
|
||||
custom_author="USER",
|
||||
)
|
||||
|
||||
response = render(
|
||||
request,
|
||||
"partials/compose-send-status.html",
|
||||
{"notice_message": "Sent.", "notice_level": "success"},
|
||||
)
|
||||
response["HX-Trigger"] = "composeMessageSent"
|
||||
return response
|
||||
return self._response(request, ok=True, message="", level="success")
|
||||
|
||||
Reference in New Issue
Block a user