Implement attachment view

This commit is contained in:
2026-02-15 18:58:58 +00:00
parent e7aac36ef9
commit 4cf75b9923
8 changed files with 914 additions and 69 deletions

View File

@@ -1,4 +1,6 @@
import asyncio
import re
from urllib.parse import urlsplit
import aiohttp
from asgiref.sync import sync_to_async
@@ -27,6 +29,50 @@ from core.models import (
)
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):
@@ -821,38 +867,69 @@ class XMPPComponent(ComponentXMPP):
recipient_domain = recipient_jid
# Extract message body
body = msg["body"] if msg["body"] else "[No Body]"
body = msg["body"] if msg["body"] else ""
attachments = []
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"):
url_value = _clean_url(att.attrib.get("url"))
if not url_value:
continue
attachments.append(
{
"url": att.attrib.get("url"),
"filename": att.attrib.get("filename"),
"content_type": att.attrib.get("content_type"),
"url": url_value,
"filename": att.attrib.get("filename")
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"):
url_value = _clean_url(oob.text)
if not url_value:
continue
attachments.append(
{
"url": oob.text,
"filename": oob.text.split("/")[-1], # Extract filename from URL
"content_type": "application/octet-stream", # Generic content-type
"url": url_value,
"filename": _filename_from_url(url_value),
"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.")
# Log extracted information with variable name annotations
log_message = (
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"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)
@@ -960,6 +1037,11 @@ class XMPPComponent(ComponentXMPP):
)
self.log.info(f"MANIP11 {manipulations}")
if not manipulations:
await self.ur.stopped_typing(
"xmpp",
identifier=identifier,
payload={"reason": "message_sent"},
)
await identifier.send(
body,
attachments,
@@ -984,6 +1066,11 @@ class XMPPComponent(ComponentXMPP):
text=result,
ts=int(now().timestamp() * 1000),
)
await self.ur.stopped_typing(
"xmpp",
identifier=identifier,
payload={"reason": "message_sent"},
)
await identifier.send(
result,
attachments,
@@ -1052,6 +1139,29 @@ class XMPPComponent(ComponentXMPP):
self.log.info(f"Sending XMPP message: {msg.xml}")
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(
self, user, person_identifier, text, is_outgoing_message, attachments=[]
):
@@ -1110,3 +1220,9 @@ class XMPPClient(ClientBase):
self.client.connect()
# 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)

View 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}"
)
)

View File

@@ -1,4 +1,7 @@
import asyncio
from asgiref.sync import sync_to_async
from django.conf import settings
from core.clients import transport
from core.clients.instagram import InstagramClient
@@ -7,6 +10,7 @@ from core.clients.whatsapp import WhatsAppClient
from core.clients.xmpp import XMPPClient
from core.messaging import history
from core.models import PersonIdentifier
from core.realtime.typing_state import set_person_typing_state
from core.util import logs
@@ -17,6 +21,10 @@ class UnifiedRouter(object):
def __init__(self, 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.info("Initialised Unified Router Interface.")
@@ -26,6 +34,42 @@ class UnifiedRouter(object):
self.whatsapp = WhatsAppClient(self, loop, "whatsapp")
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):
print("UR _start")
self.xmpp.start()
@@ -86,6 +130,23 @@ class UnifiedRouter(object):
identifier = kwargs.get("identifier")
identifiers = await self._resolve_identifier_objects(protocol, identifier)
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)(
PersonIdentifier.objects.filter(
user=src.user,
@@ -93,13 +154,34 @@ class UnifiedRouter(object):
).exclude(service=protocol)
)
for target in targets:
if target.service == "xmpp":
continue
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):
self.log.info(f"Stopped typing ({protocol}) {args} {kwargs}")
identifier = kwargs.get("identifier")
identifiers = await self._resolve_identifier_objects(protocol, identifier)
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)(
PersonIdentifier.objects.filter(
user=src.user,
@@ -107,6 +189,9 @@ class UnifiedRouter(object):
).exclude(service=protocol)
)
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)
async def reacted(self, protocol, *args, **kwargs):

View File

@@ -8,7 +8,13 @@ from asgiref.sync import sync_to_async
from django.core import signing
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):
@@ -28,11 +34,24 @@ def _fmt_ts(ts_value):
def _serialize_message(msg):
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 {
"id": str(msg.id),
"ts": int(msg.ts or 0),
"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,
"outgoing": author.upper() in {"USER", "BOT"},
}
@@ -59,14 +78,18 @@ def _load_since(user_id, service, identifier, person_id, after_ts, limit):
identifier=identifier,
).first()
if person_identifier is None:
return {"messages": [], "last_ts": after_ts}
return {"messages": [], "last_ts": after_ts, "person_id": 0}
session = ChatSession.objects.filter(
user_id=user_id,
identifier=person_identifier,
).first()
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(
user_id=user_id,
@@ -85,6 +108,7 @@ def _load_since(user_id, service, identifier, person_id, after_ts, limit):
return {
"messages": [_serialize_message(row) for row in rows],
"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()
identifier = str(payload.get("i") 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):
await send({"type": "websocket.close", "code": 4401})
@@ -117,6 +142,7 @@ async def compose_ws_application(scope, receive, send):
await send({"type": "websocket.accept"})
last_ts = 0
limit = 100
last_typing_key = ""
while True:
event = None
@@ -145,18 +171,33 @@ async def compose_ws_application(scope, receive, send):
)
messages = payload.get("messages") or []
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:
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(
{
"type": "websocket.send",
"text": json.dumps(
{
"messages": messages,
"last_ts": last_ts,
}
),
"text": json.dumps(outgoing_payload),
}
)
else:
last_ts = max(last_ts, latest)

View 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),
}

View File

@@ -62,7 +62,7 @@
<div class="compose-engage-source-row">
<div class="select is-small is-fullwidth">
<select class="engage-source-select">
<option value="">Auto</option>
<option value="auto">Auto</option>
</select>
</div>
<button type="button" class="button is-light is-small engage-refresh-btn">
@@ -108,7 +108,32 @@
{% for msg in serialized_messages %}
<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 %}">
<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">
{{ msg.display_ts }}{% if msg.author %} · {{ msg.author }}{% endif %}
</p>
@@ -118,6 +143,9 @@
<p class="compose-empty">No stored messages for this contact yet.</p>
{% endfor %}
</div>
<p id="{{ panel_id }}-typing" class="compose-typing is-hidden">
{% if person %}{{ person.name }}{% else %}Contact{% endif %} is typing...
</p>
<form
id="{{ panel_id }}-form"
@@ -198,6 +226,18 @@
#{{ panel_id }} .compose-bubble.is-out {
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 {
margin: 0 0 0.2rem 0;
white-space: pre-wrap;
@@ -216,6 +256,15 @@
color: #6f6f6f;
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 {
display: flex;
align-items: flex-end;
@@ -257,6 +306,18 @@
margin-top: 0.55rem;
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 {
position: absolute;
top: 4.2rem;
@@ -339,6 +400,40 @@
#{{ panel_id }} .compose-draft-option:last-child {
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 {
margin-top: 0.55rem;
display: flex;
@@ -366,6 +461,17 @@
from { opacity: 0; transform: translateY(4px); }
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) {
#{{ panel_id }} .compose-thread {
max-height: 52vh;
@@ -396,6 +502,7 @@
}
const statusBox = document.getElementById(panelId + "-status");
const typingNode = document.getElementById(panelId + "-typing");
const popover = document.getElementById(panelId + "-popover");
const popoverBackdrop = document.getElementById(panelId + "-popover-backdrop");
const csrfToken = "{{ csrf_token }}";
@@ -420,6 +527,7 @@
engageToken: ""
};
window.giaComposePanels[panelId] = panelState;
const triggerButtons = Array.from(panel.querySelectorAll(".js-ai-trigger"));
const toInt = function (value) {
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 row = document.createElement("div");
const outgoing = !!msg.outgoing;
@@ -455,10 +599,37 @@
const bubble = document.createElement("article");
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");
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);
} 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");
meta.className = "compose-msg-meta";
@@ -475,6 +646,7 @@
empty.remove();
}
thread.appendChild(row);
wireImageFallbacks(row);
};
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) {
if (panelState.polling || panelState.websocketReady) {
return;
@@ -513,6 +699,9 @@
}
const payload = await response.json();
appendMessages(payload.messages || [], forceScroll);
if (payload.typing) {
applyTyping(payload.typing);
}
if (payload.last_ts !== undefined && payload.last_ts !== null) {
lastTs = Math.max(lastTs, toInt(payload.last_ts));
thread.dataset.lastTs = String(lastTs);
@@ -546,6 +735,9 @@
try {
const payload = JSON.parse(event.data || "{}");
appendMessages(payload.messages || [], false);
if (payload.typing) {
applyTyping(payload.typing);
}
if (payload.last_ts !== undefined && payload.last_ts !== null) {
lastTs = Math.max(lastTs, toInt(payload.last_ts));
thread.dataset.lastTs = String(lastTs);
@@ -585,6 +777,12 @@
manualConfirm.addEventListener("change", 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) {
if (!statusBox) {
@@ -594,7 +792,26 @@
statusBox.innerHTML = "";
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 () {
@@ -609,6 +826,7 @@
card.classList.remove("is-active");
});
panelState.activePanel = null;
setActiveTrigger(null);
};
const showCard = function (kind) {
@@ -628,6 +846,7 @@
}
});
panelState.activePanel = kind;
setActiveTrigger(kind);
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 card = showCard("drafts");
if (!card) {
@@ -674,6 +905,19 @@
const drafts = Array.isArray(payload.drafts) ? payload.drafts : [];
const container = card.querySelector(".compose-ai-content");
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) {
const button = document.createElement("button");
button.type = "button";
@@ -887,13 +1131,16 @@
});
const payload = await response.json();
if (!payload.ok) {
flashCompose("is-send-fail");
setStatus(payload.error || "Engage send failed.", "danger");
return;
}
setStatus(payload.message || "Shared engage sent.", "success");
flashCompose("is-send-success");
setStatus("", "success");
hideAllCards();
poll(true);
} catch (err) {
flashCompose("is-send-fail");
setStatus("Engage send failed.", "danger");
}
});
@@ -911,12 +1158,7 @@
} else if (kind === "summary") {
loadSummary();
} else if (kind === "engage") {
const card = showCard("engage");
if (card && !card.dataset.bound) {
bindEngageControls(card);
card.dataset.bound = "1";
}
loadEngage(card);
openEngage("auto");
}
});
});
@@ -954,13 +1196,8 @@
}
});
form.addEventListener("htmx:afterRequest", function (event) {
if (event.detail && event.detail.successful) {
textarea.value = "";
autosize();
poll(true);
form.addEventListener("htmx:afterRequest", function () {
textarea.focus();
}
});
panelState.eventHandler = function () {
@@ -968,12 +1205,33 @@
};
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);
setupWebSocket();
panelState.timer = setInterval(function () {
if (!document.getElementById(panelId)) {
clearInterval(panelState.timer);
document.body.removeEventListener("composeMessageSent", panelState.eventHandler);
document.body.removeEventListener("composeSendResult", panelState.sendResultHandler);
document.removeEventListener("mousedown", panelState.docClickHandler);
if (panelState.socket) {
try {

View File

@@ -1,5 +1,5 @@
{% 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 }}
</article>
</p>
{% endif %}

View File

@@ -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")