Implement attachment view
This commit is contained in:
@@ -1,4 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import re
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
from asgiref.sync import sync_to_async
|
from asgiref.sync import sync_to_async
|
||||||
@@ -27,6 +29,50 @@ from core.models import (
|
|||||||
)
|
)
|
||||||
from core.util import logs
|
from core.util import logs
|
||||||
|
|
||||||
|
URL_PATTERN = re.compile(r"https?://[^\s<>'\"\\]+")
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_url(value):
|
||||||
|
return str(value or "").strip().rstrip(".,);:!?\"'")
|
||||||
|
|
||||||
|
|
||||||
|
def _filename_from_url(url_value):
|
||||||
|
path = urlsplit(str(url_value or "")).path
|
||||||
|
name = path.rsplit("/", 1)[-1]
|
||||||
|
return name or "attachment"
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_xml_attachment_urls(message_stanza):
|
||||||
|
urls = []
|
||||||
|
|
||||||
|
def _add(candidate):
|
||||||
|
cleaned = _clean_url(candidate)
|
||||||
|
if not cleaned:
|
||||||
|
return
|
||||||
|
if not cleaned.startswith("http://") and not cleaned.startswith("https://"):
|
||||||
|
return
|
||||||
|
if cleaned not in urls:
|
||||||
|
urls.append(cleaned)
|
||||||
|
|
||||||
|
# Explicit attachments and OOB payloads.
|
||||||
|
for node in message_stanza.xml.findall(".//{urn:xmpp:attachments}attachment"):
|
||||||
|
_add(node.attrib.get("url"))
|
||||||
|
for node in message_stanza.xml.findall(".//{jabber:x:oob}x/{jabber:x:oob}url"):
|
||||||
|
_add(node.text)
|
||||||
|
|
||||||
|
# XMPP references frequently carry attachment URIs.
|
||||||
|
for node in message_stanza.xml.findall(".//{urn:xmpp:reference:0}reference"):
|
||||||
|
_add(node.attrib.get("uri"))
|
||||||
|
|
||||||
|
# Generic fallback for custom namespaces and rich message payloads.
|
||||||
|
for node in message_stanza.xml.iter():
|
||||||
|
for key in ("url", "uri", "href", "src"):
|
||||||
|
_add(node.attrib.get(key))
|
||||||
|
for match in URL_PATTERN.findall(str(node.text or "")):
|
||||||
|
_add(match)
|
||||||
|
|
||||||
|
return urls
|
||||||
|
|
||||||
|
|
||||||
class XMPPComponent(ComponentXMPP):
|
class XMPPComponent(ComponentXMPP):
|
||||||
|
|
||||||
@@ -821,38 +867,69 @@ class XMPPComponent(ComponentXMPP):
|
|||||||
recipient_domain = recipient_jid
|
recipient_domain = recipient_jid
|
||||||
|
|
||||||
# Extract message body
|
# Extract message body
|
||||||
body = msg["body"] if msg["body"] else "[No Body]"
|
body = msg["body"] if msg["body"] else ""
|
||||||
|
|
||||||
attachments = []
|
attachments = []
|
||||||
self.log.info(f"Full XMPP Message: {ET.tostring(msg.xml, encoding='unicode')}")
|
self.log.info(f"Full XMPP Message: {ET.tostring(msg.xml, encoding='unicode')}")
|
||||||
|
|
||||||
# Extract attachments from standard XMPP <attachments> (if present)
|
# Extract attachments from standard XMPP payloads.
|
||||||
for att in msg.xml.findall(".//{urn:xmpp:attachments}attachment"):
|
for att in msg.xml.findall(".//{urn:xmpp:attachments}attachment"):
|
||||||
|
url_value = _clean_url(att.attrib.get("url"))
|
||||||
|
if not url_value:
|
||||||
|
continue
|
||||||
attachments.append(
|
attachments.append(
|
||||||
{
|
{
|
||||||
"url": att.attrib.get("url"),
|
"url": url_value,
|
||||||
"filename": att.attrib.get("filename"),
|
"filename": att.attrib.get("filename")
|
||||||
"content_type": att.attrib.get("content_type"),
|
or _filename_from_url(url_value),
|
||||||
|
"content_type": att.attrib.get("content_type")
|
||||||
|
or "application/octet-stream",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Extract attachments from XEP-0066 <x><url> format (Out of Band Data)
|
# Extract attachments from XEP-0066 OOB payloads.
|
||||||
for oob in msg.xml.findall(".//{jabber:x:oob}x/{jabber:x:oob}url"):
|
for oob in msg.xml.findall(".//{jabber:x:oob}x/{jabber:x:oob}url"):
|
||||||
|
url_value = _clean_url(oob.text)
|
||||||
|
if not url_value:
|
||||||
|
continue
|
||||||
attachments.append(
|
attachments.append(
|
||||||
{
|
{
|
||||||
"url": oob.text,
|
"url": url_value,
|
||||||
"filename": oob.text.split("/")[-1], # Extract filename from URL
|
"filename": _filename_from_url(url_value),
|
||||||
"content_type": "application/octet-stream", # Generic content-type
|
"content_type": "application/octet-stream",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Fallback extraction for alternate attachment encodings.
|
||||||
|
extracted_urls = _extract_xml_attachment_urls(msg)
|
||||||
|
existing_urls = {str(item.get("url") or "").strip() for item in attachments}
|
||||||
|
for url_value in extracted_urls:
|
||||||
|
if url_value in existing_urls:
|
||||||
|
continue
|
||||||
|
attachments.append(
|
||||||
|
{
|
||||||
|
"url": url_value,
|
||||||
|
"filename": _filename_from_url(url_value),
|
||||||
|
"content_type": "application/octet-stream",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (not body or body.strip().lower() in {"[no body]", "(no text)"}) and attachments:
|
||||||
|
attachment_urls = [
|
||||||
|
str(item.get("url") or "").strip()
|
||||||
|
for item in attachments
|
||||||
|
if str(item.get("url") or "").strip()
|
||||||
|
]
|
||||||
|
if attachment_urls:
|
||||||
|
body = "\n".join(attachment_urls)
|
||||||
|
|
||||||
self.log.info(f"Extracted {len(attachments)} attachments from XMPP message.")
|
self.log.info(f"Extracted {len(attachments)} attachments from XMPP message.")
|
||||||
# Log extracted information with variable name annotations
|
# Log extracted information with variable name annotations
|
||||||
log_message = (
|
log_message = (
|
||||||
f"Sender JID: {sender_jid}, Sender Username: {sender_username}, Sender Domain: {sender_domain}, "
|
f"Sender JID: {sender_jid}, Sender Username: {sender_username}, Sender Domain: {sender_domain}, "
|
||||||
f"Sender Resource: {sender_resource if sender_resource else '[No Resource]'}, "
|
f"Sender Resource: {sender_resource if sender_resource else '[No Resource]'}, "
|
||||||
f"Recipient JID: {recipient_jid}, Recipient Username: {recipient_username}, Recipient Domain: {recipient_domain}, "
|
f"Recipient JID: {recipient_jid}, Recipient Username: {recipient_username}, Recipient Domain: {recipient_domain}, "
|
||||||
f"Body: {body}"
|
f"Body: {body or '[No Body]'}"
|
||||||
)
|
)
|
||||||
self.log.info(log_message)
|
self.log.info(log_message)
|
||||||
|
|
||||||
@@ -960,6 +1037,11 @@ class XMPPComponent(ComponentXMPP):
|
|||||||
)
|
)
|
||||||
self.log.info(f"MANIP11 {manipulations}")
|
self.log.info(f"MANIP11 {manipulations}")
|
||||||
if not manipulations:
|
if not manipulations:
|
||||||
|
await self.ur.stopped_typing(
|
||||||
|
"xmpp",
|
||||||
|
identifier=identifier,
|
||||||
|
payload={"reason": "message_sent"},
|
||||||
|
)
|
||||||
await identifier.send(
|
await identifier.send(
|
||||||
body,
|
body,
|
||||||
attachments,
|
attachments,
|
||||||
@@ -984,6 +1066,11 @@ class XMPPComponent(ComponentXMPP):
|
|||||||
text=result,
|
text=result,
|
||||||
ts=int(now().timestamp() * 1000),
|
ts=int(now().timestamp() * 1000),
|
||||||
)
|
)
|
||||||
|
await self.ur.stopped_typing(
|
||||||
|
"xmpp",
|
||||||
|
identifier=identifier,
|
||||||
|
payload={"reason": "message_sent"},
|
||||||
|
)
|
||||||
await identifier.send(
|
await identifier.send(
|
||||||
result,
|
result,
|
||||||
attachments,
|
attachments,
|
||||||
@@ -1052,6 +1139,29 @@ class XMPPComponent(ComponentXMPP):
|
|||||||
self.log.info(f"Sending XMPP message: {msg.xml}")
|
self.log.info(f"Sending XMPP message: {msg.xml}")
|
||||||
msg.send()
|
msg.send()
|
||||||
|
|
||||||
|
async def send_chat_state(self, recipient_jid, sender_jid, started):
|
||||||
|
"""Send XMPP chat-state update to the client."""
|
||||||
|
msg = self.make_message(mto=recipient_jid, mfrom=sender_jid, mtype="chat")
|
||||||
|
state_tag = "composing" if started else "paused"
|
||||||
|
msg.xml.append(
|
||||||
|
ET.Element(f"{{http://jabber.org/protocol/chatstates}}{state_tag}")
|
||||||
|
)
|
||||||
|
self.log.info(
|
||||||
|
"Sending XMPP chat-state %s: %s -> %s",
|
||||||
|
state_tag,
|
||||||
|
sender_jid,
|
||||||
|
recipient_jid,
|
||||||
|
)
|
||||||
|
msg.send()
|
||||||
|
|
||||||
|
async def send_typing_for_person(self, user, person_identifier, started):
|
||||||
|
sender_jid = (
|
||||||
|
f"{person_identifier.person.name.lower()}|"
|
||||||
|
f"{person_identifier.service}@{settings.XMPP_JID}"
|
||||||
|
)
|
||||||
|
recipient_jid = f"{user.username}@{settings.XMPP_ADDRESS}"
|
||||||
|
await self.send_chat_state(recipient_jid, sender_jid, started)
|
||||||
|
|
||||||
async def send_from_external(
|
async def send_from_external(
|
||||||
self, user, person_identifier, text, is_outgoing_message, attachments=[]
|
self, user, person_identifier, text, is_outgoing_message, attachments=[]
|
||||||
):
|
):
|
||||||
@@ -1110,3 +1220,9 @@ class XMPPClient(ClientBase):
|
|||||||
|
|
||||||
self.client.connect()
|
self.client.connect()
|
||||||
# self.client.process()
|
# self.client.process()
|
||||||
|
|
||||||
|
async def start_typing_for_person(self, user, person_identifier):
|
||||||
|
await self.client.send_typing_for_person(user, person_identifier, True)
|
||||||
|
|
||||||
|
async def stop_typing_for_person(self, user, person_identifier):
|
||||||
|
await self.client.send_typing_for_person(user, person_identifier, False)
|
||||||
|
|||||||
162
core/management/commands/backfill_xmpp_attachment_urls.py
Normal file
162
core/management/commands/backfill_xmpp_attachment_urls.py
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.db.models import Q
|
||||||
|
|
||||||
|
from core.models import Message, MessageEvent
|
||||||
|
|
||||||
|
|
||||||
|
EMPTY_TEXT_VALUES = {
|
||||||
|
"",
|
||||||
|
"[No Body]",
|
||||||
|
"[no body]",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_url(value):
|
||||||
|
if not value:
|
||||||
|
return ""
|
||||||
|
text = str(value).strip().rstrip(".,);:!?\"'")
|
||||||
|
if text.startswith("http://") or text.startswith("https://"):
|
||||||
|
return text
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_urls_from_attachment_blob(blob):
|
||||||
|
urls = []
|
||||||
|
if isinstance(blob, str):
|
||||||
|
normalized = _normalize_url(blob)
|
||||||
|
if normalized:
|
||||||
|
urls.append(normalized)
|
||||||
|
return urls
|
||||||
|
|
||||||
|
if isinstance(blob, dict):
|
||||||
|
for key in ("url", "source_url", "download_url", "proxy_url", "href"):
|
||||||
|
normalized = _normalize_url(blob.get(key))
|
||||||
|
if normalized:
|
||||||
|
urls.append(normalized)
|
||||||
|
nested = blob.get("attachments")
|
||||||
|
if isinstance(nested, list):
|
||||||
|
for row in nested:
|
||||||
|
urls.extend(_extract_urls_from_attachment_blob(row))
|
||||||
|
return urls
|
||||||
|
|
||||||
|
if isinstance(blob, list):
|
||||||
|
for row in blob:
|
||||||
|
urls.extend(_extract_urls_from_attachment_blob(row))
|
||||||
|
return urls
|
||||||
|
|
||||||
|
|
||||||
|
def _uniq(values):
|
||||||
|
seen = set()
|
||||||
|
output = []
|
||||||
|
for value in values:
|
||||||
|
if value in seen:
|
||||||
|
continue
|
||||||
|
seen.add(value)
|
||||||
|
output.append(value)
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = (
|
||||||
|
"Backfill empty Message.text rows originating from XMPP by recovering "
|
||||||
|
"attachment URLs from MessageEvent metadata."
|
||||||
|
)
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
"--apply",
|
||||||
|
action="store_true",
|
||||||
|
help="Persist updates. Without this flag, runs as dry-run.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--user-id",
|
||||||
|
type=int,
|
||||||
|
default=None,
|
||||||
|
help="Limit processing to one user ID.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--limit",
|
||||||
|
type=int,
|
||||||
|
default=0,
|
||||||
|
help="Maximum number of candidate rows to inspect (0 = no limit).",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _candidate_events(self, message):
|
||||||
|
linked = MessageEvent.objects.filter(
|
||||||
|
user=message.user,
|
||||||
|
raw_payload_ref__legacy_message_id=str(message.id),
|
||||||
|
)
|
||||||
|
if linked.exists():
|
||||||
|
return linked
|
||||||
|
|
||||||
|
# Fallback heuristic for older rows with missing legacy refs.
|
||||||
|
window = 2000
|
||||||
|
return MessageEvent.objects.filter(
|
||||||
|
user=message.user,
|
||||||
|
source_system="xmpp",
|
||||||
|
ts__gte=int(message.ts) - window,
|
||||||
|
ts__lte=int(message.ts) + window,
|
||||||
|
).exclude(attachments=[])
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
apply_changes = bool(options.get("apply"))
|
||||||
|
user_id = options.get("user_id")
|
||||||
|
limit = int(options.get("limit") or 0)
|
||||||
|
|
||||||
|
queryset = Message.objects.filter(
|
||||||
|
sender_uuid__iexact="xmpp",
|
||||||
|
).filter(
|
||||||
|
Q(text__isnull=True) | Q(text__exact="") | Q(text__iexact="[No Body]")
|
||||||
|
)
|
||||||
|
if user_id:
|
||||||
|
queryset = queryset.filter(user_id=user_id)
|
||||||
|
queryset = queryset.order_by("ts", "id")
|
||||||
|
if limit > 0:
|
||||||
|
queryset = queryset[:limit]
|
||||||
|
|
||||||
|
inspected = 0
|
||||||
|
recoverable = 0
|
||||||
|
updated = 0
|
||||||
|
unrecoverable = 0
|
||||||
|
|
||||||
|
for message in queryset.iterator():
|
||||||
|
inspected += 1
|
||||||
|
current_text = str(message.text or "").strip()
|
||||||
|
if current_text not in EMPTY_TEXT_VALUES:
|
||||||
|
continue
|
||||||
|
|
||||||
|
urls = []
|
||||||
|
for event in self._candidate_events(message):
|
||||||
|
urls.extend(_extract_urls_from_attachment_blob(event.attachments))
|
||||||
|
urls.extend(
|
||||||
|
_extract_urls_from_attachment_blob(event.raw_payload_ref or {})
|
||||||
|
)
|
||||||
|
if urls:
|
||||||
|
break
|
||||||
|
|
||||||
|
urls = _uniq(urls)
|
||||||
|
if not urls:
|
||||||
|
unrecoverable += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
recoverable += 1
|
||||||
|
new_text = "\n".join(urls)
|
||||||
|
if apply_changes:
|
||||||
|
message.text = new_text
|
||||||
|
message.save(update_fields=["text"])
|
||||||
|
updated += 1
|
||||||
|
else:
|
||||||
|
self.stdout.write(
|
||||||
|
f"[dry-run] {message.id}: would set {len(urls)} URL(s)"
|
||||||
|
)
|
||||||
|
|
||||||
|
mode = "apply" if apply_changes else "dry-run"
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
"XMPP attachment URL backfill complete "
|
||||||
|
f"({mode}): inspected={inspected}, "
|
||||||
|
f"recoverable={recoverable}, "
|
||||||
|
f"updated={updated}, "
|
||||||
|
f"unrecoverable={unrecoverable}"
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
from asgiref.sync import sync_to_async
|
from asgiref.sync import sync_to_async
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
from core.clients import transport
|
from core.clients import transport
|
||||||
from core.clients.instagram import InstagramClient
|
from core.clients.instagram import InstagramClient
|
||||||
@@ -7,6 +10,7 @@ from core.clients.whatsapp import WhatsAppClient
|
|||||||
from core.clients.xmpp import XMPPClient
|
from core.clients.xmpp import XMPPClient
|
||||||
from core.messaging import history
|
from core.messaging import history
|
||||||
from core.models import PersonIdentifier
|
from core.models import PersonIdentifier
|
||||||
|
from core.realtime.typing_state import set_person_typing_state
|
||||||
from core.util import logs
|
from core.util import logs
|
||||||
|
|
||||||
|
|
||||||
@@ -17,6 +21,10 @@ class UnifiedRouter(object):
|
|||||||
|
|
||||||
def __init__(self, loop):
|
def __init__(self, loop):
|
||||||
self.loop = loop
|
self.loop = loop
|
||||||
|
self.typing_auto_stop_seconds = int(
|
||||||
|
getattr(settings, "XMPP_TYPING_AUTO_STOP_SECONDS", 3)
|
||||||
|
)
|
||||||
|
self._typing_stop_tasks = {}
|
||||||
|
|
||||||
self.log = logs.get_logger("router")
|
self.log = logs.get_logger("router")
|
||||||
self.log.info("Initialised Unified Router Interface.")
|
self.log.info("Initialised Unified Router Interface.")
|
||||||
@@ -26,6 +34,42 @@ class UnifiedRouter(object):
|
|||||||
self.whatsapp = WhatsAppClient(self, loop, "whatsapp")
|
self.whatsapp = WhatsAppClient(self, loop, "whatsapp")
|
||||||
self.instagram = InstagramClient(self, loop, "instagram")
|
self.instagram = InstagramClient(self, loop, "instagram")
|
||||||
|
|
||||||
|
def _typing_task_key(self, target):
|
||||||
|
return (
|
||||||
|
int(target.user_id),
|
||||||
|
int(target.person_id),
|
||||||
|
str(target.service),
|
||||||
|
str(target.identifier),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _cancel_typing_timer(self, key):
|
||||||
|
existing = self._typing_stop_tasks.pop(key, None)
|
||||||
|
if existing and not existing.done():
|
||||||
|
existing.cancel()
|
||||||
|
|
||||||
|
def _schedule_typing_auto_stop(self, target):
|
||||||
|
key = self._typing_task_key(target)
|
||||||
|
self._cancel_typing_timer(key)
|
||||||
|
delay = max(1, int(self.typing_auto_stop_seconds))
|
||||||
|
|
||||||
|
async def _timer():
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
await transport.stop_typing(target.service, target.identifier)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
return
|
||||||
|
except Exception as exc:
|
||||||
|
self.log.warning(
|
||||||
|
"Typing auto-stop failed for %s/%s: %s",
|
||||||
|
target.service,
|
||||||
|
target.identifier,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
self._typing_stop_tasks.pop(key, None)
|
||||||
|
|
||||||
|
self._typing_stop_tasks[key] = self.loop.create_task(_timer())
|
||||||
|
|
||||||
def _start(self):
|
def _start(self):
|
||||||
print("UR _start")
|
print("UR _start")
|
||||||
self.xmpp.start()
|
self.xmpp.start()
|
||||||
@@ -86,6 +130,23 @@ class UnifiedRouter(object):
|
|||||||
identifier = kwargs.get("identifier")
|
identifier = kwargs.get("identifier")
|
||||||
identifiers = await self._resolve_identifier_objects(protocol, identifier)
|
identifiers = await self._resolve_identifier_objects(protocol, identifier)
|
||||||
for src in identifiers:
|
for src in identifiers:
|
||||||
|
if protocol != "xmpp":
|
||||||
|
set_person_typing_state(
|
||||||
|
user_id=src.user_id,
|
||||||
|
person_id=src.person_id,
|
||||||
|
started=True,
|
||||||
|
source_service=protocol,
|
||||||
|
display_name=src.person.name,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await self.xmpp.start_typing_for_person(src.user, src)
|
||||||
|
except Exception as exc:
|
||||||
|
self.log.warning(
|
||||||
|
"Failed to relay typing-start to XMPP for %s: %s",
|
||||||
|
src.identifier,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
targets = await sync_to_async(list)(
|
targets = await sync_to_async(list)(
|
||||||
PersonIdentifier.objects.filter(
|
PersonIdentifier.objects.filter(
|
||||||
user=src.user,
|
user=src.user,
|
||||||
@@ -93,13 +154,34 @@ class UnifiedRouter(object):
|
|||||||
).exclude(service=protocol)
|
).exclude(service=protocol)
|
||||||
)
|
)
|
||||||
for target in targets:
|
for target in targets:
|
||||||
|
if target.service == "xmpp":
|
||||||
|
continue
|
||||||
await transport.start_typing(target.service, target.identifier)
|
await transport.start_typing(target.service, target.identifier)
|
||||||
|
if protocol == "xmpp":
|
||||||
|
self._schedule_typing_auto_stop(target)
|
||||||
|
|
||||||
async def stopped_typing(self, protocol, *args, **kwargs):
|
async def stopped_typing(self, protocol, *args, **kwargs):
|
||||||
self.log.info(f"Stopped typing ({protocol}) {args} {kwargs}")
|
self.log.info(f"Stopped typing ({protocol}) {args} {kwargs}")
|
||||||
identifier = kwargs.get("identifier")
|
identifier = kwargs.get("identifier")
|
||||||
identifiers = await self._resolve_identifier_objects(protocol, identifier)
|
identifiers = await self._resolve_identifier_objects(protocol, identifier)
|
||||||
for src in identifiers:
|
for src in identifiers:
|
||||||
|
if protocol != "xmpp":
|
||||||
|
set_person_typing_state(
|
||||||
|
user_id=src.user_id,
|
||||||
|
person_id=src.person_id,
|
||||||
|
started=False,
|
||||||
|
source_service=protocol,
|
||||||
|
display_name=src.person.name,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await self.xmpp.stop_typing_for_person(src.user, src)
|
||||||
|
except Exception as exc:
|
||||||
|
self.log.warning(
|
||||||
|
"Failed to relay typing-stop to XMPP for %s: %s",
|
||||||
|
src.identifier,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
targets = await sync_to_async(list)(
|
targets = await sync_to_async(list)(
|
||||||
PersonIdentifier.objects.filter(
|
PersonIdentifier.objects.filter(
|
||||||
user=src.user,
|
user=src.user,
|
||||||
@@ -107,6 +189,9 @@ class UnifiedRouter(object):
|
|||||||
).exclude(service=protocol)
|
).exclude(service=protocol)
|
||||||
)
|
)
|
||||||
for target in targets:
|
for target in targets:
|
||||||
|
if target.service == "xmpp":
|
||||||
|
continue
|
||||||
|
self._cancel_typing_timer(self._typing_task_key(target))
|
||||||
await transport.stop_typing(target.service, target.identifier)
|
await transport.stop_typing(target.service, target.identifier)
|
||||||
|
|
||||||
async def reacted(self, protocol, *args, **kwargs):
|
async def reacted(self, protocol, *args, **kwargs):
|
||||||
|
|||||||
@@ -8,7 +8,13 @@ from asgiref.sync import sync_to_async
|
|||||||
from django.core import signing
|
from django.core import signing
|
||||||
|
|
||||||
from core.models import ChatSession, Message, PersonIdentifier
|
from core.models import ChatSession, Message, PersonIdentifier
|
||||||
from core.views.compose import COMPOSE_WS_TOKEN_SALT
|
from core.realtime.typing_state import get_person_typing_state
|
||||||
|
from core.views.compose import (
|
||||||
|
COMPOSE_WS_TOKEN_SALT,
|
||||||
|
_image_urls_from_text,
|
||||||
|
_is_url_only_text,
|
||||||
|
_looks_like_image_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _safe_int(value, default=0):
|
def _safe_int(value, default=0):
|
||||||
@@ -28,11 +34,24 @@ def _fmt_ts(ts_value):
|
|||||||
|
|
||||||
def _serialize_message(msg):
|
def _serialize_message(msg):
|
||||||
author = str(msg.custom_author or "").strip()
|
author = str(msg.custom_author or "").strip()
|
||||||
|
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 "")
|
||||||
return {
|
return {
|
||||||
"id": str(msg.id),
|
"id": str(msg.id),
|
||||||
"ts": int(msg.ts or 0),
|
"ts": int(msg.ts or 0),
|
||||||
"display_ts": _fmt_ts(msg.ts),
|
"display_ts": _fmt_ts(msg.ts),
|
||||||
"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,
|
"author": author,
|
||||||
"outgoing": author.upper() in {"USER", "BOT"},
|
"outgoing": author.upper() in {"USER", "BOT"},
|
||||||
}
|
}
|
||||||
@@ -59,14 +78,18 @@ def _load_since(user_id, service, identifier, person_id, after_ts, limit):
|
|||||||
identifier=identifier,
|
identifier=identifier,
|
||||||
).first()
|
).first()
|
||||||
if person_identifier is None:
|
if person_identifier is None:
|
||||||
return {"messages": [], "last_ts": after_ts}
|
return {"messages": [], "last_ts": after_ts, "person_id": 0}
|
||||||
|
|
||||||
session = ChatSession.objects.filter(
|
session = ChatSession.objects.filter(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
identifier=person_identifier,
|
identifier=person_identifier,
|
||||||
).first()
|
).first()
|
||||||
if session is None:
|
if session is None:
|
||||||
return {"messages": [], "last_ts": after_ts}
|
return {
|
||||||
|
"messages": [],
|
||||||
|
"last_ts": after_ts,
|
||||||
|
"person_id": int(person_identifier.person_id),
|
||||||
|
}
|
||||||
|
|
||||||
qs = Message.objects.filter(
|
qs = Message.objects.filter(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
@@ -85,6 +108,7 @@ def _load_since(user_id, service, identifier, person_id, after_ts, limit):
|
|||||||
return {
|
return {
|
||||||
"messages": [_serialize_message(row) for row in rows],
|
"messages": [_serialize_message(row) for row in rows],
|
||||||
"last_ts": int(newest or after_ts or 0),
|
"last_ts": int(newest or after_ts or 0),
|
||||||
|
"person_id": int(person_identifier.person_id),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -109,6 +133,7 @@ async def compose_ws_application(scope, receive, send):
|
|||||||
service = str(payload.get("s") or "").strip()
|
service = str(payload.get("s") or "").strip()
|
||||||
identifier = str(payload.get("i") or "").strip()
|
identifier = str(payload.get("i") or "").strip()
|
||||||
person_id = str(payload.get("p") or "").strip()
|
person_id = str(payload.get("p") or "").strip()
|
||||||
|
resolved_person_id = _safe_int(person_id)
|
||||||
|
|
||||||
if user_id <= 0 or (not identifier and not person_id):
|
if user_id <= 0 or (not identifier and not person_id):
|
||||||
await send({"type": "websocket.close", "code": 4401})
|
await send({"type": "websocket.close", "code": 4401})
|
||||||
@@ -117,6 +142,7 @@ async def compose_ws_application(scope, receive, send):
|
|||||||
await send({"type": "websocket.accept"})
|
await send({"type": "websocket.accept"})
|
||||||
last_ts = 0
|
last_ts = 0
|
||||||
limit = 100
|
limit = 100
|
||||||
|
last_typing_key = ""
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
event = None
|
event = None
|
||||||
@@ -145,18 +171,33 @@ async def compose_ws_application(scope, receive, send):
|
|||||||
)
|
)
|
||||||
messages = payload.get("messages") or []
|
messages = payload.get("messages") or []
|
||||||
latest = _safe_int(payload.get("last_ts"), last_ts)
|
latest = _safe_int(payload.get("last_ts"), last_ts)
|
||||||
|
if resolved_person_id <= 0:
|
||||||
|
resolved_person_id = _safe_int(payload.get("person_id"), 0)
|
||||||
|
typing_state = get_person_typing_state(
|
||||||
|
user_id=user_id,
|
||||||
|
person_id=resolved_person_id,
|
||||||
|
)
|
||||||
|
typing_key = json.dumps(typing_state, sort_keys=True)
|
||||||
|
typing_changed = typing_key != last_typing_key
|
||||||
|
if typing_changed:
|
||||||
|
last_typing_key = typing_key
|
||||||
|
|
||||||
|
outgoing_payload = {}
|
||||||
if messages:
|
if messages:
|
||||||
last_ts = max(last_ts, latest)
|
last_ts = max(last_ts, latest)
|
||||||
|
outgoing_payload["messages"] = messages
|
||||||
|
outgoing_payload["last_ts"] = last_ts
|
||||||
|
else:
|
||||||
|
last_ts = max(last_ts, latest)
|
||||||
|
outgoing_payload["last_ts"] = last_ts
|
||||||
|
|
||||||
|
if typing_changed:
|
||||||
|
outgoing_payload["typing"] = typing_state
|
||||||
|
|
||||||
|
if messages or typing_changed:
|
||||||
await send(
|
await send(
|
||||||
{
|
{
|
||||||
"type": "websocket.send",
|
"type": "websocket.send",
|
||||||
"text": json.dumps(
|
"text": json.dumps(outgoing_payload),
|
||||||
{
|
|
||||||
"messages": messages,
|
|
||||||
"last_ts": last_ts,
|
|
||||||
}
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
last_ts = max(last_ts, latest)
|
|
||||||
|
|||||||
74
core/realtime/typing_state.py
Normal file
74
core/realtime/typing_state.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import time
|
||||||
|
|
||||||
|
from django.core.cache import cache
|
||||||
|
|
||||||
|
TYPING_TTL_SECONDS = 12
|
||||||
|
|
||||||
|
|
||||||
|
def _person_key(user_id, person_id):
|
||||||
|
return f"compose:typing:user:{int(user_id)}:person:{int(person_id)}"
|
||||||
|
|
||||||
|
|
||||||
|
def set_person_typing_state(
|
||||||
|
*,
|
||||||
|
user_id,
|
||||||
|
person_id,
|
||||||
|
started,
|
||||||
|
source_service="",
|
||||||
|
display_name="",
|
||||||
|
):
|
||||||
|
if not user_id or not person_id:
|
||||||
|
return
|
||||||
|
now_ms = int(time.time() * 1000)
|
||||||
|
state = {
|
||||||
|
"typing": bool(started),
|
||||||
|
"source_service": str(source_service or ""),
|
||||||
|
"display_name": str(display_name or ""),
|
||||||
|
"updated_ts": now_ms,
|
||||||
|
"expires_ts": (
|
||||||
|
now_ms + (TYPING_TTL_SECONDS * 1000) if started else now_ms
|
||||||
|
),
|
||||||
|
}
|
||||||
|
cache.set(
|
||||||
|
_person_key(user_id, person_id),
|
||||||
|
state,
|
||||||
|
timeout=max(TYPING_TTL_SECONDS * 2, 30),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_person_typing_state(*, user_id, person_id):
|
||||||
|
if not user_id or not person_id:
|
||||||
|
return {
|
||||||
|
"typing": False,
|
||||||
|
"source_service": "",
|
||||||
|
"display_name": "",
|
||||||
|
"updated_ts": 0,
|
||||||
|
"expires_ts": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
key = _person_key(user_id, person_id)
|
||||||
|
state = dict(cache.get(key) or {})
|
||||||
|
if not state:
|
||||||
|
return {
|
||||||
|
"typing": False,
|
||||||
|
"source_service": "",
|
||||||
|
"display_name": "",
|
||||||
|
"updated_ts": 0,
|
||||||
|
"expires_ts": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
now_ms = int(time.time() * 1000)
|
||||||
|
is_typing = bool(state.get("typing"))
|
||||||
|
expires_ts = int(state.get("expires_ts") or 0)
|
||||||
|
if is_typing and expires_ts and now_ms > expires_ts:
|
||||||
|
state["typing"] = False
|
||||||
|
state["updated_ts"] = now_ms
|
||||||
|
cache.set(key, state, timeout=max(TYPING_TTL_SECONDS * 2, 30))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"typing": bool(state.get("typing")),
|
||||||
|
"source_service": str(state.get("source_service") or ""),
|
||||||
|
"display_name": str(state.get("display_name") or ""),
|
||||||
|
"updated_ts": int(state.get("updated_ts") or 0),
|
||||||
|
"expires_ts": int(state.get("expires_ts") or 0),
|
||||||
|
}
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
<div class="compose-engage-source-row">
|
<div class="compose-engage-source-row">
|
||||||
<div class="select is-small is-fullwidth">
|
<div class="select is-small is-fullwidth">
|
||||||
<select class="engage-source-select">
|
<select class="engage-source-select">
|
||||||
<option value="">Auto</option>
|
<option value="auto">Auto</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="button is-light is-small engage-refresh-btn">
|
<button type="button" class="button is-light is-small engage-refresh-btn">
|
||||||
@@ -108,7 +108,32 @@
|
|||||||
{% for msg in serialized_messages %}
|
{% for msg in serialized_messages %}
|
||||||
<div class="compose-row {% if msg.outgoing %}is-out{% else %}is-in{% endif %}" data-ts="{{ msg.ts }}">
|
<div class="compose-row {% if msg.outgoing %}is-out{% else %}is-in{% endif %}" data-ts="{{ msg.ts }}">
|
||||||
<article class="compose-bubble {% if msg.outgoing %}is-out{% else %}is-in{% endif %}">
|
<article class="compose-bubble {% if msg.outgoing %}is-out{% else %}is-in{% endif %}">
|
||||||
<p class="compose-body">{{ msg.text|default:"(no text)" }}</p>
|
{% if msg.image_urls %}
|
||||||
|
{% for image_url in msg.image_urls %}
|
||||||
|
<figure class="compose-media">
|
||||||
|
<img
|
||||||
|
class="compose-image"
|
||||||
|
src="{{ image_url }}"
|
||||||
|
alt="Attachment"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async">
|
||||||
|
</figure>
|
||||||
|
{% endfor %}
|
||||||
|
{% elif msg.image_url %}
|
||||||
|
<figure class="compose-media">
|
||||||
|
<img
|
||||||
|
class="compose-image"
|
||||||
|
src="{{ msg.image_url }}"
|
||||||
|
alt="Attachment"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async">
|
||||||
|
</figure>
|
||||||
|
{% endif %}
|
||||||
|
{% if not msg.hide_text %}
|
||||||
|
<p class="compose-body">{{ msg.display_text|default:"(no text)" }}</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="compose-body compose-image-fallback is-hidden">(no text)</p>
|
||||||
|
{% endif %}
|
||||||
<p class="compose-msg-meta">
|
<p class="compose-msg-meta">
|
||||||
{{ msg.display_ts }}{% if msg.author %} · {{ msg.author }}{% endif %}
|
{{ msg.display_ts }}{% if msg.author %} · {{ msg.author }}{% endif %}
|
||||||
</p>
|
</p>
|
||||||
@@ -118,6 +143,9 @@
|
|||||||
<p class="compose-empty">No stored messages for this contact yet.</p>
|
<p class="compose-empty">No stored messages for this contact yet.</p>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
<p id="{{ panel_id }}-typing" class="compose-typing is-hidden">
|
||||||
|
{% if person %}{{ person.name }}{% else %}Contact{% endif %} is typing...
|
||||||
|
</p>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
id="{{ panel_id }}-form"
|
id="{{ panel_id }}-form"
|
||||||
@@ -198,6 +226,18 @@
|
|||||||
#{{ panel_id }} .compose-bubble.is-out {
|
#{{ panel_id }} .compose-bubble.is-out {
|
||||||
background: #eef6ff;
|
background: #eef6ff;
|
||||||
}
|
}
|
||||||
|
#{{ panel_id }} .compose-media {
|
||||||
|
margin: 0 0 0.28rem 0;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-image {
|
||||||
|
display: block;
|
||||||
|
max-width: min(100%, 22rem);
|
||||||
|
max-height: 18rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.14);
|
||||||
|
object-fit: cover;
|
||||||
|
background: #f8f8f8;
|
||||||
|
}
|
||||||
#{{ panel_id }} .compose-body {
|
#{{ panel_id }} .compose-body {
|
||||||
margin: 0 0 0.2rem 0;
|
margin: 0 0 0.2rem 0;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
@@ -216,6 +256,15 @@
|
|||||||
color: #6f6f6f;
|
color: #6f6f6f;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
}
|
}
|
||||||
|
#{{ panel_id }} .compose-typing {
|
||||||
|
margin: 0 0 0.5rem 0.2rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: #4e6381;
|
||||||
|
min-height: 1rem;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-typing.is-hidden {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
#{{ panel_id }} .compose-composer-capsule {
|
#{{ panel_id }} .compose-composer-capsule {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
@@ -257,6 +306,18 @@
|
|||||||
margin-top: 0.55rem;
|
margin-top: 0.55rem;
|
||||||
min-height: 1.1rem;
|
min-height: 1.1rem;
|
||||||
}
|
}
|
||||||
|
#{{ panel_id }} .compose-status-line {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
color: #5f6a7a;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-status-line.is-warning,
|
||||||
|
#{{ panel_id }} .compose-status-line.is-danger {
|
||||||
|
color: #c0392b;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-status-line.is-success {
|
||||||
|
color: #2f855a;
|
||||||
|
}
|
||||||
#{{ panel_id }} .compose-ai-popover {
|
#{{ panel_id }} .compose-ai-popover {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 4.2rem;
|
top: 4.2rem;
|
||||||
@@ -339,6 +400,40 @@
|
|||||||
#{{ panel_id }} .compose-draft-option:last-child {
|
#{{ panel_id }} .compose-draft-option:last-child {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
#{{ panel_id }} .buttons {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .js-ai-trigger {
|
||||||
|
position: relative;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .js-ai-trigger.is-expanded {
|
||||||
|
background: #eef6ff;
|
||||||
|
border-color: #8bb2e6;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .js-ai-trigger.is-expanded::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
bottom: -0.34rem;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
border-left: 6px solid transparent;
|
||||||
|
border-right: 6px solid transparent;
|
||||||
|
border-top: 6px solid #8bb2e6;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-image-fallback.is-hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
#{{ panel_id }}.is-send-success .compose-composer-capsule {
|
||||||
|
animation: composeSendFlash 360ms ease-out;
|
||||||
|
}
|
||||||
|
#{{ panel_id }}.is-send-fail .compose-composer-capsule {
|
||||||
|
animation: composeSendShake 330ms ease-out;
|
||||||
|
border-color: rgba(192, 57, 43, 0.7);
|
||||||
|
}
|
||||||
#{{ panel_id }} .compose-ai-safety {
|
#{{ panel_id }} .compose-ai-safety {
|
||||||
margin-top: 0.55rem;
|
margin-top: 0.55rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -366,6 +461,17 @@
|
|||||||
from { opacity: 0; transform: translateY(4px); }
|
from { opacity: 0; transform: translateY(4px); }
|
||||||
to { opacity: 1; transform: translateY(0); }
|
to { opacity: 1; transform: translateY(0); }
|
||||||
}
|
}
|
||||||
|
@keyframes composeSendFlash {
|
||||||
|
0% { box-shadow: 0 0 0 0 rgba(60, 132, 218, 0); }
|
||||||
|
25% { box-shadow: 0 0 0 3px rgba(60, 132, 218, 0.25); }
|
||||||
|
100% { box-shadow: 0 0 0 0 rgba(60, 132, 218, 0); }
|
||||||
|
}
|
||||||
|
@keyframes composeSendShake {
|
||||||
|
0%, 100% { transform: translateX(0); }
|
||||||
|
25% { transform: translateX(-2px); }
|
||||||
|
50% { transform: translateX(2px); }
|
||||||
|
75% { transform: translateX(-1px); }
|
||||||
|
}
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
#{{ panel_id }} .compose-thread {
|
#{{ panel_id }} .compose-thread {
|
||||||
max-height: 52vh;
|
max-height: 52vh;
|
||||||
@@ -396,6 +502,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const statusBox = document.getElementById(panelId + "-status");
|
const statusBox = document.getElementById(panelId + "-status");
|
||||||
|
const typingNode = document.getElementById(panelId + "-typing");
|
||||||
const popover = document.getElementById(panelId + "-popover");
|
const popover = document.getElementById(panelId + "-popover");
|
||||||
const popoverBackdrop = document.getElementById(panelId + "-popover-backdrop");
|
const popoverBackdrop = document.getElementById(panelId + "-popover-backdrop");
|
||||||
const csrfToken = "{{ csrf_token }}";
|
const csrfToken = "{{ csrf_token }}";
|
||||||
@@ -420,6 +527,7 @@
|
|||||||
engageToken: ""
|
engageToken: ""
|
||||||
};
|
};
|
||||||
window.giaComposePanels[panelId] = panelState;
|
window.giaComposePanels[panelId] = panelState;
|
||||||
|
const triggerButtons = Array.from(panel.querySelectorAll(".js-ai-trigger"));
|
||||||
|
|
||||||
const toInt = function (value) {
|
const toInt = function (value) {
|
||||||
const parsed = parseInt(value || "0", 10);
|
const parsed = parseInt(value || "0", 10);
|
||||||
@@ -446,6 +554,42 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const wireImageFallbacks = function (rootNode) {
|
||||||
|
const scope = rootNode || thread;
|
||||||
|
if (!scope) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
scope.querySelectorAll(".compose-bubble").forEach(function (bubble) {
|
||||||
|
const fallback = bubble.querySelector(".compose-image-fallback");
|
||||||
|
const refresh = function () {
|
||||||
|
if (!fallback) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const remaining = bubble.querySelectorAll(".compose-image").length;
|
||||||
|
fallback.classList.toggle("is-hidden", remaining > 0);
|
||||||
|
};
|
||||||
|
bubble.querySelectorAll(".compose-image").forEach(function (img) {
|
||||||
|
if (img.dataset.fallbackBound === "1") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
img.dataset.fallbackBound = "1";
|
||||||
|
img.addEventListener("error", function () {
|
||||||
|
const figure = img.closest(".compose-media");
|
||||||
|
if (figure) {
|
||||||
|
figure.remove();
|
||||||
|
}
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
img.addEventListener("load", function () {
|
||||||
|
if (fallback) {
|
||||||
|
fallback.classList.add("is-hidden");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const appendBubble = function (msg) {
|
const appendBubble = function (msg) {
|
||||||
const row = document.createElement("div");
|
const row = document.createElement("div");
|
||||||
const outgoing = !!msg.outgoing;
|
const outgoing = !!msg.outgoing;
|
||||||
@@ -455,10 +599,37 @@
|
|||||||
const bubble = document.createElement("article");
|
const bubble = document.createElement("article");
|
||||||
bubble.className = "compose-bubble " + (outgoing ? "is-out" : "is-in");
|
bubble.className = "compose-bubble " + (outgoing ? "is-out" : "is-in");
|
||||||
|
|
||||||
|
const imageCandidates = Array.isArray(msg.image_urls) && msg.image_urls.length
|
||||||
|
? msg.image_urls
|
||||||
|
: (msg.image_url ? [msg.image_url] : []);
|
||||||
|
imageCandidates.forEach(function (candidateUrl) {
|
||||||
|
const figure = document.createElement("figure");
|
||||||
|
figure.className = "compose-media";
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.className = "compose-image";
|
||||||
|
img.src = String(candidateUrl);
|
||||||
|
img.alt = "Attachment";
|
||||||
|
img.loading = "lazy";
|
||||||
|
img.decoding = "async";
|
||||||
|
figure.appendChild(img);
|
||||||
|
bubble.appendChild(figure);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!msg.hide_text) {
|
||||||
const body = document.createElement("p");
|
const body = document.createElement("p");
|
||||||
body.className = "compose-body";
|
body.className = "compose-body";
|
||||||
body.textContent = String(msg.text || "(no text)");
|
body.textContent = String(
|
||||||
|
msg.display_text ||
|
||||||
|
msg.text ||
|
||||||
|
(msg.image_url ? "" : "(no text)")
|
||||||
|
);
|
||||||
bubble.appendChild(body);
|
bubble.appendChild(body);
|
||||||
|
} else {
|
||||||
|
const fallback = document.createElement("p");
|
||||||
|
fallback.className = "compose-body compose-image-fallback is-hidden";
|
||||||
|
fallback.textContent = "(no text)";
|
||||||
|
bubble.appendChild(fallback);
|
||||||
|
}
|
||||||
|
|
||||||
const meta = document.createElement("p");
|
const meta = document.createElement("p");
|
||||||
meta.className = "compose-msg-meta";
|
meta.className = "compose-msg-meta";
|
||||||
@@ -475,6 +646,7 @@
|
|||||||
empty.remove();
|
empty.remove();
|
||||||
}
|
}
|
||||||
thread.appendChild(row);
|
thread.appendChild(row);
|
||||||
|
wireImageFallbacks(row);
|
||||||
};
|
};
|
||||||
|
|
||||||
const appendMessages = function (messages, forceScroll) {
|
const appendMessages = function (messages, forceScroll) {
|
||||||
@@ -489,6 +661,20 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const applyTyping = function (typingPayload) {
|
||||||
|
if (!typingNode || !typingPayload || typeof typingPayload !== "object") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const isTyping = !!typingPayload.typing;
|
||||||
|
if (!isTyping) {
|
||||||
|
typingNode.classList.add("is-hidden");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const displayName = String(typingPayload.display_name || "").trim();
|
||||||
|
typingNode.textContent = (displayName || "Contact") + " is typing...";
|
||||||
|
typingNode.classList.remove("is-hidden");
|
||||||
|
};
|
||||||
|
|
||||||
const poll = async function (forceScroll) {
|
const poll = async function (forceScroll) {
|
||||||
if (panelState.polling || panelState.websocketReady) {
|
if (panelState.polling || panelState.websocketReady) {
|
||||||
return;
|
return;
|
||||||
@@ -513,6 +699,9 @@
|
|||||||
}
|
}
|
||||||
const payload = await response.json();
|
const payload = await response.json();
|
||||||
appendMessages(payload.messages || [], forceScroll);
|
appendMessages(payload.messages || [], forceScroll);
|
||||||
|
if (payload.typing) {
|
||||||
|
applyTyping(payload.typing);
|
||||||
|
}
|
||||||
if (payload.last_ts !== undefined && payload.last_ts !== null) {
|
if (payload.last_ts !== undefined && payload.last_ts !== null) {
|
||||||
lastTs = Math.max(lastTs, toInt(payload.last_ts));
|
lastTs = Math.max(lastTs, toInt(payload.last_ts));
|
||||||
thread.dataset.lastTs = String(lastTs);
|
thread.dataset.lastTs = String(lastTs);
|
||||||
@@ -546,6 +735,9 @@
|
|||||||
try {
|
try {
|
||||||
const payload = JSON.parse(event.data || "{}");
|
const payload = JSON.parse(event.data || "{}");
|
||||||
appendMessages(payload.messages || [], false);
|
appendMessages(payload.messages || [], false);
|
||||||
|
if (payload.typing) {
|
||||||
|
applyTyping(payload.typing);
|
||||||
|
}
|
||||||
if (payload.last_ts !== undefined && payload.last_ts !== null) {
|
if (payload.last_ts !== undefined && payload.last_ts !== null) {
|
||||||
lastTs = Math.max(lastTs, toInt(payload.last_ts));
|
lastTs = Math.max(lastTs, toInt(payload.last_ts));
|
||||||
thread.dataset.lastTs = String(lastTs);
|
thread.dataset.lastTs = String(lastTs);
|
||||||
@@ -585,6 +777,12 @@
|
|||||||
manualConfirm.addEventListener("change", updateManualSafety);
|
manualConfirm.addEventListener("change", updateManualSafety);
|
||||||
}
|
}
|
||||||
updateManualSafety();
|
updateManualSafety();
|
||||||
|
try {
|
||||||
|
const initialTyping = JSON.parse("{{ typing_state_json|escapejs }}");
|
||||||
|
applyTyping(initialTyping);
|
||||||
|
} catch (err) {
|
||||||
|
// Ignore invalid initial typing state payload.
|
||||||
|
}
|
||||||
|
|
||||||
const setStatus = function (message, level) {
|
const setStatus = function (message, level) {
|
||||||
if (!statusBox) {
|
if (!statusBox) {
|
||||||
@@ -594,7 +792,26 @@
|
|||||||
statusBox.innerHTML = "";
|
statusBox.innerHTML = "";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
statusBox.innerHTML = '<article class="notification is-' + (level || "info") + ' is-light" style="padding:0.45rem 0.65rem; margin:0;">' + message + "</article>";
|
const row = document.createElement("p");
|
||||||
|
row.className = "compose-status-line is-" + (level || "info");
|
||||||
|
row.textContent = String(message);
|
||||||
|
statusBox.innerHTML = "";
|
||||||
|
statusBox.appendChild(row);
|
||||||
|
};
|
||||||
|
|
||||||
|
const flashCompose = function (className) {
|
||||||
|
panel.classList.remove("is-send-success", "is-send-fail");
|
||||||
|
void panel.offsetWidth;
|
||||||
|
panel.classList.add(className);
|
||||||
|
window.setTimeout(function () {
|
||||||
|
panel.classList.remove(className);
|
||||||
|
}, 420);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setActiveTrigger = function (kind) {
|
||||||
|
triggerButtons.forEach(function (button) {
|
||||||
|
button.classList.toggle("is-expanded", !!kind && button.dataset.kind === kind);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const hideAllCards = function () {
|
const hideAllCards = function () {
|
||||||
@@ -609,6 +826,7 @@
|
|||||||
card.classList.remove("is-active");
|
card.classList.remove("is-active");
|
||||||
});
|
});
|
||||||
panelState.activePanel = null;
|
panelState.activePanel = null;
|
||||||
|
setActiveTrigger(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const showCard = function (kind) {
|
const showCard = function (kind) {
|
||||||
@@ -628,6 +846,7 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
panelState.activePanel = kind;
|
panelState.activePanel = kind;
|
||||||
|
setActiveTrigger(kind);
|
||||||
return active;
|
return active;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -653,6 +872,18 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openEngage = function (sourceRef) {
|
||||||
|
const engageCard = showCard("engage");
|
||||||
|
if (!engageCard) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!engageCard.dataset.bound) {
|
||||||
|
bindEngageControls(engageCard);
|
||||||
|
engageCard.dataset.bound = "1";
|
||||||
|
}
|
||||||
|
loadEngage(engageCard, sourceRef || "auto");
|
||||||
|
};
|
||||||
|
|
||||||
const loadDrafts = async function () {
|
const loadDrafts = async function () {
|
||||||
const card = showCard("drafts");
|
const card = showCard("drafts");
|
||||||
if (!card) {
|
if (!card) {
|
||||||
@@ -674,6 +905,19 @@
|
|||||||
const drafts = Array.isArray(payload.drafts) ? payload.drafts : [];
|
const drafts = Array.isArray(payload.drafts) ? payload.drafts : [];
|
||||||
const container = card.querySelector(".compose-ai-content");
|
const container = card.querySelector(".compose-ai-content");
|
||||||
container.innerHTML = "";
|
container.innerHTML = "";
|
||||||
|
const engageButton = document.createElement("button");
|
||||||
|
engageButton.type = "button";
|
||||||
|
engageButton.className = "button is-link is-light compose-draft-option";
|
||||||
|
const engageStrong = document.createElement("strong");
|
||||||
|
engageStrong.textContent = "Custom Engage: ";
|
||||||
|
const engageText = document.createElement("span");
|
||||||
|
engageText.textContent = "Choose a source or write your own engagement text.";
|
||||||
|
engageButton.appendChild(engageStrong);
|
||||||
|
engageButton.appendChild(engageText);
|
||||||
|
engageButton.addEventListener("click", function () {
|
||||||
|
openEngage("custom");
|
||||||
|
});
|
||||||
|
container.appendChild(engageButton);
|
||||||
drafts.forEach(function (item) {
|
drafts.forEach(function (item) {
|
||||||
const button = document.createElement("button");
|
const button = document.createElement("button");
|
||||||
button.type = "button";
|
button.type = "button";
|
||||||
@@ -887,13 +1131,16 @@
|
|||||||
});
|
});
|
||||||
const payload = await response.json();
|
const payload = await response.json();
|
||||||
if (!payload.ok) {
|
if (!payload.ok) {
|
||||||
|
flashCompose("is-send-fail");
|
||||||
setStatus(payload.error || "Engage send failed.", "danger");
|
setStatus(payload.error || "Engage send failed.", "danger");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setStatus(payload.message || "Shared engage sent.", "success");
|
flashCompose("is-send-success");
|
||||||
|
setStatus("", "success");
|
||||||
hideAllCards();
|
hideAllCards();
|
||||||
poll(true);
|
poll(true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
flashCompose("is-send-fail");
|
||||||
setStatus("Engage send failed.", "danger");
|
setStatus("Engage send failed.", "danger");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -911,12 +1158,7 @@
|
|||||||
} else if (kind === "summary") {
|
} else if (kind === "summary") {
|
||||||
loadSummary();
|
loadSummary();
|
||||||
} else if (kind === "engage") {
|
} else if (kind === "engage") {
|
||||||
const card = showCard("engage");
|
openEngage("auto");
|
||||||
if (card && !card.dataset.bound) {
|
|
||||||
bindEngageControls(card);
|
|
||||||
card.dataset.bound = "1";
|
|
||||||
}
|
|
||||||
loadEngage(card);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -954,13 +1196,8 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
form.addEventListener("htmx:afterRequest", function (event) {
|
form.addEventListener("htmx:afterRequest", function () {
|
||||||
if (event.detail && event.detail.successful) {
|
|
||||||
textarea.value = "";
|
|
||||||
autosize();
|
|
||||||
poll(true);
|
|
||||||
textarea.focus();
|
textarea.focus();
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
panelState.eventHandler = function () {
|
panelState.eventHandler = function () {
|
||||||
@@ -968,12 +1205,33 @@
|
|||||||
};
|
};
|
||||||
document.body.addEventListener("composeMessageSent", panelState.eventHandler);
|
document.body.addEventListener("composeMessageSent", panelState.eventHandler);
|
||||||
|
|
||||||
|
panelState.sendResultHandler = function (event) {
|
||||||
|
const detail = (event && event.detail) || {};
|
||||||
|
const ok = !!detail.ok;
|
||||||
|
if (ok) {
|
||||||
|
flashCompose("is-send-success");
|
||||||
|
setStatus("", "success");
|
||||||
|
textarea.value = "";
|
||||||
|
autosize();
|
||||||
|
poll(true);
|
||||||
|
} else {
|
||||||
|
flashCompose("is-send-fail");
|
||||||
|
if (detail.message) {
|
||||||
|
setStatus(detail.message, detail.level || "danger");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
textarea.focus();
|
||||||
|
};
|
||||||
|
document.body.addEventListener("composeSendResult", panelState.sendResultHandler);
|
||||||
|
|
||||||
|
wireImageFallbacks(thread);
|
||||||
scrollToBottom(true);
|
scrollToBottom(true);
|
||||||
setupWebSocket();
|
setupWebSocket();
|
||||||
panelState.timer = setInterval(function () {
|
panelState.timer = setInterval(function () {
|
||||||
if (!document.getElementById(panelId)) {
|
if (!document.getElementById(panelId)) {
|
||||||
clearInterval(panelState.timer);
|
clearInterval(panelState.timer);
|
||||||
document.body.removeEventListener("composeMessageSent", panelState.eventHandler);
|
document.body.removeEventListener("composeMessageSent", panelState.eventHandler);
|
||||||
|
document.body.removeEventListener("composeSendResult", panelState.sendResultHandler);
|
||||||
document.removeEventListener("mousedown", panelState.docClickHandler);
|
document.removeEventListener("mousedown", panelState.docClickHandler);
|
||||||
if (panelState.socket) {
|
if (panelState.socket) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% if notice_message %}
|
{% if notice_message %}
|
||||||
<article class="notification is-{{ notice_level|default:'info' }} is-light" style="padding: 0.45rem 0.65rem; margin: 0;">
|
<p class="compose-status-line is-{{ notice_level|default:'info' }}">
|
||||||
{{ notice_message }}
|
{{ notice_message }}
|
||||||
</article>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import json
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timezone as dt_timezone
|
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 asgiref.sync import async_to_sync
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
@@ -27,11 +28,35 @@ from core.models import (
|
|||||||
Person,
|
Person,
|
||||||
PersonIdentifier,
|
PersonIdentifier,
|
||||||
)
|
)
|
||||||
|
from core.realtime.typing_state import get_person_typing_state
|
||||||
from core.views.workspace import _build_engage_payload, _parse_draft_options
|
from core.views.workspace import _build_engage_payload, _parse_draft_options
|
||||||
|
|
||||||
COMPOSE_WS_TOKEN_SALT = "compose-ws"
|
COMPOSE_WS_TOKEN_SALT = "compose-ws"
|
||||||
COMPOSE_ENGAGE_TOKEN_SALT = "compose-engage"
|
COMPOSE_ENGAGE_TOKEN_SALT = "compose-engage"
|
||||||
COMPOSE_AI_CACHE_TTL = 60 * 30
|
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:
|
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"}
|
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:
|
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()
|
author = str(msg.custom_author or "").strip()
|
||||||
return {
|
return {
|
||||||
"id": str(msg.id),
|
"id": str(msg.id),
|
||||||
"ts": int(msg.ts or 0),
|
"ts": int(msg.ts or 0),
|
||||||
"display_ts": _format_ts_label(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,
|
"author": author,
|
||||||
"outgoing": _is_outgoing(msg),
|
"outgoing": _is_outgoing(msg),
|
||||||
}
|
}
|
||||||
@@ -400,6 +487,10 @@ def _panel_context(
|
|||||||
|
|
||||||
unique_raw = f"{base['service']}|{base['identifier']}|{request.user.id}"
|
unique_raw = f"{base['service']}|{base['identifier']}|{request.user.id}"
|
||||||
unique = hashlib.sha1(unique_raw.encode("utf-8")).hexdigest()[:12]
|
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 {
|
return {
|
||||||
"service": base["service"],
|
"service": base["service"],
|
||||||
@@ -430,6 +521,7 @@ def _panel_context(
|
|||||||
),
|
),
|
||||||
"manual_icon_class": "fa-solid fa-paper-plane",
|
"manual_icon_class": "fa-solid fa-paper-plane",
|
||||||
"panel_id": f"compose-panel-{unique}",
|
"panel_id": f"compose-panel-{unique}",
|
||||||
|
"typing_state_json": json.dumps(typing_state),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -560,6 +652,10 @@ class ComposeThread(LoginRequiredMixin, View):
|
|||||||
payload = {
|
payload = {
|
||||||
"messages": [_serialize_message(msg) for msg in messages],
|
"messages": [_serialize_message(msg) for msg in messages],
|
||||||
"last_ts": latest_ts,
|
"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)
|
return JsonResponse(payload)
|
||||||
|
|
||||||
@@ -891,6 +987,28 @@ class ComposeEngageSend(LoginRequiredMixin, View):
|
|||||||
|
|
||||||
|
|
||||||
class ComposeSend(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):
|
def post(self, request):
|
||||||
service = _default_service(request.POST.get("service"))
|
service = _default_service(request.POST.get("service"))
|
||||||
identifier = str(request.POST.get("identifier") or "").strip()
|
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_arm = str(request.POST.get("failsafe_arm") or "").strip()
|
||||||
failsafe_confirm = str(request.POST.get("failsafe_confirm") or "").strip()
|
failsafe_confirm = str(request.POST.get("failsafe_confirm") or "").strip()
|
||||||
if failsafe_arm != "1" or failsafe_confirm != "1":
|
if failsafe_arm != "1" or failsafe_confirm != "1":
|
||||||
return render(
|
return self._response(
|
||||||
request,
|
request,
|
||||||
"partials/compose-send-status.html",
|
ok=False,
|
||||||
{
|
message="Enable send confirmation before sending.",
|
||||||
"notice_message": "Enable send confirmation before sending.",
|
level="warning",
|
||||||
"notice_level": "warning",
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
text = str(request.POST.get("text") or "").strip()
|
text = str(request.POST.get("text") or "").strip()
|
||||||
if not text:
|
if not text:
|
||||||
return render(
|
return self._response(
|
||||||
request,
|
request,
|
||||||
"partials/compose-send-status.html",
|
ok=False,
|
||||||
{"notice_message": "Message is empty.", "notice_level": "danger"},
|
message="Message is empty.",
|
||||||
|
level="danger",
|
||||||
)
|
)
|
||||||
|
|
||||||
base = _context_base(request.user, service, identifier, person)
|
base = _context_base(request.user, service, identifier, person)
|
||||||
@@ -933,13 +1050,11 @@ class ComposeSend(LoginRequiredMixin, View):
|
|||||||
attachments=[],
|
attachments=[],
|
||||||
)
|
)
|
||||||
if not ts:
|
if not ts:
|
||||||
return render(
|
return self._response(
|
||||||
request,
|
request,
|
||||||
"partials/compose-send-status.html",
|
ok=False,
|
||||||
{
|
message="Send failed. Check service account state.",
|
||||||
"notice_message": "Send failed. Check service account state.",
|
level="danger",
|
||||||
"notice_level": "danger",
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if base["person_identifier"] is not None:
|
if base["person_identifier"] is not None:
|
||||||
@@ -957,10 +1072,4 @@ class ComposeSend(LoginRequiredMixin, View):
|
|||||||
custom_author="USER",
|
custom_author="USER",
|
||||||
)
|
)
|
||||||
|
|
||||||
response = render(
|
return self._response(request, ok=True, message="", level="success")
|
||||||
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