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)