Implement reactions and image sync

This commit is contained in:
2026-02-17 21:23:03 +00:00
parent 6bc8a0ab88
commit dc28745fc3
14 changed files with 2011 additions and 202 deletions

View File

@@ -1,5 +1,8 @@
import asyncio
import mimetypes
import re
import time
import uuid
from urllib.parse import urlsplit
import aiohttp
@@ -12,7 +15,7 @@ from slixmpp.stanza import Message
from slixmpp.xmlstream import register_stanza_plugin
from slixmpp.xmlstream.stanzabase import ET
from core.clients import ClientBase
from core.clients import ClientBase, transport
from core.messaging import ai, history, replies, utils
from core.models import (
ChatSession,
@@ -30,6 +33,9 @@ from core.models import (
from core.util import logs
URL_PATTERN = re.compile(r"https?://[^\s<>'\"\\]+")
EMOJI_ONLY_PATTERN = re.compile(
r"^[\U0001F300-\U0001FAFF\u2600-\u27BF\uFE0F\u200D\u2640-\u2642\u2764]+$"
)
def _clean_url(value):
@@ -42,6 +48,12 @@ def _filename_from_url(url_value):
return name or "attachment"
def _content_type_from_filename_or_url(url_value, default="application/octet-stream"):
filename = _filename_from_url(url_value)
guessed, _ = mimetypes.guess_type(filename)
return guessed or default
def _extract_xml_attachment_urls(message_stanza):
urls = []
@@ -74,6 +86,46 @@ def _extract_xml_attachment_urls(message_stanza):
return urls
def _extract_xmpp_reaction(message_stanza):
nodes = message_stanza.xml.findall(".//{urn:xmpp:reactions:0}reactions")
if not nodes:
return None
node = nodes[0]
target_id = str(node.attrib.get("id") or "").strip()
emojis = []
for child in node.findall("{urn:xmpp:reactions:0}reaction"):
value = str(child.text or "").strip()
if value:
emojis.append(value)
return {
"target_id": target_id,
"emoji": emojis[0] if emojis else "",
"remove": len(emojis) == 0,
}
def _extract_xmpp_reply_target_id(message_stanza):
reply = message_stanza.xml.find(".//{urn:xmpp:reply:0}reply")
if reply is None:
return ""
return str(reply.attrib.get("id") or reply.attrib.get("to") or "").strip()
def _parse_greentext_reaction(body_text):
lines = [line.strip() for line in str(body_text or "").splitlines() if line.strip()]
if len(lines) != 2:
return None
if not lines[0].startswith(">"):
return None
quoted = lines[0][1:].strip()
emoji = lines[1].strip()
if not quoted or not emoji:
return None
if not EMOJI_ONLY_PATTERN.match(emoji):
return None
return {"quoted_text": quoted, "emoji": emoji}
class XMPPComponent(ComponentXMPP):
"""
@@ -82,6 +134,7 @@ class XMPPComponent(ComponentXMPP):
def __init__(self, ur, jid, secret, server, port):
self.ur = ur
self._upload_config_warned = False
self.log = logs.get_logger("XMPP")
@@ -130,6 +183,8 @@ class XMPPComponent(ComponentXMPP):
self.log.error(f"Failed to enable Carbons: {e}")
def get_identifier(self, msg):
xmpp_message_id = str(msg.get("id") or "").strip()
# Extract sender JID (full format: user@domain/resource)
sender_jid = str(msg["from"])
@@ -798,10 +853,43 @@ class XMPPComponent(ComponentXMPP):
or getattr(settings, "XMPP_UPLOAD_JID", "")
).strip()
if not upload_service_jid:
self.log.error(
"XMPP upload service is not configured. Set XMPP_UPLOAD_SERVICE."
)
return None
discovered = None
try:
discovered = await self["xep_0363"].find_upload_service()
except Exception as exc:
self.log.debug("XMPP upload service discovery failed: %s", exc)
if discovered:
discovered_jid = ""
try:
discovered_jid = str(getattr(discovered, "jid", "") or "").strip()
except Exception:
discovered_jid = ""
if not discovered_jid:
raw_discovered = str(discovered or "").strip()
if raw_discovered.startswith("<"):
try:
node = ET.fromstring(raw_discovered)
discovered_jid = str(node.attrib.get("from") or "").strip()
except Exception:
discovered_jid = ""
else:
discovered_jid = raw_discovered
upload_service_jid = discovered_jid
if upload_service_jid:
self.log.info(
"Discovered XMPP upload service via XEP-0363: %s",
upload_service_jid,
)
else:
if not self._upload_config_warned:
self.log.warning(
"XMPP upload service not configured/discoverable; skipping attachment upload. "
"Set XMPP_UPLOAD_SERVICE (or XMPP_UPLOAD_JID)."
)
self._upload_config_warned = True
return None
try:
slot = await self["xep_0363"].request_slot(
@@ -849,6 +937,8 @@ class XMPPComponent(ComponentXMPP):
def sym(value):
msg.reply(f"[>] {value}").send()
xmpp_message_id = str(msg.get("id") or "").strip()
# Extract sender JID (full format: user@domain/resource)
sender_jid = str(msg["from"])
@@ -872,6 +962,9 @@ class XMPPComponent(ComponentXMPP):
# Extract message body
body = msg["body"] if msg["body"] else ""
parsed_reaction = _extract_xmpp_reaction(msg)
parsed_reply_target = _extract_xmpp_reply_target_id(msg)
greentext_reaction = _parse_greentext_reaction(body)
attachments = []
self.log.debug(
@@ -898,11 +991,12 @@ class XMPPComponent(ComponentXMPP):
url_value = _clean_url(oob.text)
if not url_value:
continue
guessed_content_type = _content_type_from_filename_or_url(url_value)
attachments.append(
{
"url": url_value,
"filename": _filename_from_url(url_value),
"content_type": "application/octet-stream",
"content_type": guessed_content_type,
}
)
@@ -912,11 +1006,12 @@ class XMPPComponent(ComponentXMPP):
for url_value in extracted_urls:
if url_value in existing_urls:
continue
guessed_content_type = _content_type_from_filename_or_url(url_value)
attachments.append(
{
"url": url_value,
"filename": _filename_from_url(url_value),
"content_type": "application/octet-stream",
"content_type": guessed_content_type,
}
)
@@ -931,6 +1026,17 @@ class XMPPComponent(ComponentXMPP):
if attachment_urls:
body = "\n".join(attachment_urls)
relay_body = body
attachment_urls_for_body = [
str(item.get("url") or "").strip()
for item in attachments
if str(item.get("url") or "").strip()
]
if attachment_urls_for_body:
joined_urls = "\n".join(attachment_urls_for_body).strip()
if str(relay_body or "").strip() == joined_urls:
relay_body = ""
self.log.debug("Extracted %s attachments from XMPP message", len(attachments))
# Log extracted information with variable name annotations
log_message = (
@@ -1021,6 +1127,106 @@ class XMPPComponent(ComponentXMPP):
# sym(str(person.__dict__))
# sym(f"Service: {recipient_service}")
if parsed_reaction or greentext_reaction:
# TODO(web-ui-react): expose explicit web compose reaction actions
# that call this same bridge path (without text heuristics).
# TODO(edit-sync): extend bridge mapping to include edit message ids
# and reconcile upstream edit capability differences in UI.
# TODO(retract-sync): propagate delete/retract state through this
# same mapping layer for protocol parity.
reaction_payload = parsed_reaction or {
"target_id": parsed_reply_target,
"emoji": str((greentext_reaction or {}).get("emoji") or ""),
"remove": False,
}
if not str(reaction_payload.get("target_id") or "").strip():
text_hint = str((greentext_reaction or {}).get("quoted_text") or "")
hint_match = transport.resolve_bridge_from_text_hint(
user_id=identifier.user_id,
person_id=identifier.person_id,
service=recipient_service,
text_hint=text_hint,
)
reaction_payload["target_id"] = str(
(hint_match or {}).get("xmpp_message_id") or ""
)
self.log.debug(
"reaction-bridge xmpp-inbound actor=%s service=%s target_xmpp_id=%s emoji=%s remove=%s via=%s",
sender_username,
recipient_service,
str(reaction_payload.get("target_id") or "") or "-",
str(reaction_payload.get("emoji") or "") or "-",
bool(reaction_payload.get("remove")),
"xmpp:reactions" if parsed_reaction else "greentext",
)
bridge = transport.resolve_bridge_from_xmpp(
user_id=identifier.user_id,
person_id=identifier.person_id,
service=recipient_service,
xmpp_message_id=str(reaction_payload.get("target_id") or ""),
)
if not bridge:
bridge = await history.resolve_bridge_ref(
user=identifier.user,
identifier=identifier,
source_service=recipient_service,
xmpp_message_id=str(reaction_payload.get("target_id") or ""),
)
if not bridge:
self.log.warning(
"reaction-bridge xmpp-resolve-miss actor=%s service=%s target_xmpp_id=%s",
sender_username,
recipient_service,
str(reaction_payload.get("target_id") or "") or "-",
)
sym("Could not find upstream message for this reaction.")
return
sent_ok = await transport.send_reaction(
recipient_service,
identifier.identifier,
emoji=str(reaction_payload.get("emoji") or ""),
target_message_id=str((bridge or {}).get("upstream_message_id") or ""),
target_timestamp=int((bridge or {}).get("upstream_ts") or 0),
target_author=str((bridge or {}).get("upstream_author") or ""),
remove=bool(reaction_payload.get("remove")),
)
if not sent_ok:
self.log.warning(
"reaction-bridge upstream-send-failed actor=%s service=%s recipient=%s target_upstream_id=%s target_upstream_ts=%s",
sender_username,
recipient_service,
identifier.identifier,
str((bridge or {}).get("upstream_message_id") or "") or "-",
int((bridge or {}).get("upstream_ts") or 0),
)
sym("Upstream protocol did not accept this reaction.")
return
await history.apply_reaction(
user=identifier.user,
identifier=identifier,
target_message_id=str((bridge or {}).get("local_message_id") or ""),
target_ts=int((bridge or {}).get("upstream_ts") or 0),
emoji=str(reaction_payload.get("emoji") or ""),
source_service="xmpp",
actor=sender_username,
remove=bool(reaction_payload.get("remove")),
payload={
"target_xmpp_id": str(reaction_payload.get("target_id") or ""),
"xmpp_message_id": xmpp_message_id,
},
)
self.log.debug(
"reaction-bridge xmpp-apply-ok actor=%s service=%s local_message_id=%s",
sender_username,
recipient_service,
str((bridge or {}).get("local_message_id") or "") or "-",
)
return
# tss = await identifier.send(body, attachments=attachments)
# AM FIXING https://git.zm.is/XF/GIA/issues/5
session, _ = await sync_to_async(ChatSession.objects.get_or_create)(
@@ -1028,7 +1234,7 @@ class XMPPComponent(ComponentXMPP):
user=identifier.user,
)
self.log.debug("Storing outbound XMPP message in history")
await history.store_message(
local_message = await history.store_message(
session=session,
sender="XMPP",
text=body,
@@ -1051,8 +1257,14 @@ class XMPPComponent(ComponentXMPP):
payload={"reason": "message_sent"},
)
await identifier.send(
body,
relay_body,
attachments,
metadata={
"xmpp_source_id": xmpp_message_id,
"xmpp_source_ts": int(now().timestamp() * 1000),
"xmpp_body": relay_body,
"legacy_message_id": str(local_message.id),
},
)
self.log.debug("Message sent unaltered")
return
@@ -1061,7 +1273,7 @@ class XMPPComponent(ComponentXMPP):
chat_history = await history.get_chat_history(session)
await utils.update_last_interaction(session)
prompt = replies.generate_mutate_reply_prompt(
body,
relay_body,
identifier.person,
manip,
chat_history,
@@ -1082,6 +1294,12 @@ class XMPPComponent(ComponentXMPP):
await identifier.send(
result,
attachments,
metadata={
"xmpp_source_id": xmpp_message_id,
"xmpp_source_ts": int(now().timestamp() * 1000),
"xmpp_body": result,
"legacy_message_id": str(local_message.id),
},
)
self.log.debug("Message sent with modifications")
@@ -1123,10 +1341,13 @@ class XMPPComponent(ComponentXMPP):
)
# Send XMPP message immediately after successful upload
await self.send_xmpp_message(
xmpp_msg_id = await self.send_xmpp_message(
recipient_jid, sender_jid, upload_url, attachment_url=upload_url
)
return upload_url
return {
"url": upload_url,
"xmpp_message_id": xmpp_msg_id,
}
except Exception as e:
self.log.error(f"Error uploading {att['filename']} to XMPP: {e}")
@@ -1137,6 +1358,9 @@ class XMPPComponent(ComponentXMPP):
):
"""Sends an XMPP message with either text or an attachment URL."""
msg = self.make_message(mto=recipient_jid, mfrom=sender_jid, mtype="chat")
if not msg.get("id"):
msg["id"] = uuid.uuid4().hex
msg_id = str(msg.get("id") or "").strip()
msg["body"] = body_text # Body must contain only text or the URL
if attachment_url:
@@ -1148,6 +1372,127 @@ class XMPPComponent(ComponentXMPP):
self.log.debug("Sending XMPP message: %s", msg.xml)
msg.send()
return msg_id
async def send_xmpp_reaction(
self,
recipient_jid,
sender_jid,
*,
target_xmpp_id: str,
emoji: str,
remove: bool = False,
):
msg = self.make_message(mto=recipient_jid, mfrom=sender_jid, mtype="chat")
if not msg.get("id"):
msg["id"] = uuid.uuid4().hex
msg["body"] = ""
reactions_node = ET.Element(
"{urn:xmpp:reactions:0}reactions",
{"id": str(target_xmpp_id or "").strip()},
)
if not remove and str(emoji or "").strip():
reaction_node = ET.SubElement(
reactions_node,
"{urn:xmpp:reactions:0}reaction",
)
reaction_node.text = str(emoji)
msg.xml.append(reactions_node)
msg.send()
return str(msg.get("id") or "").strip()
async def apply_external_reaction(
self,
user,
person_identifier,
*,
source_service,
emoji,
remove,
upstream_message_id="",
upstream_ts=0,
actor="",
payload=None,
):
self.log.debug(
"reaction-bridge external-in source=%s user=%s person=%s upstream_id=%s upstream_ts=%s emoji=%s remove=%s",
source_service,
user.id,
person_identifier.person_id,
str(upstream_message_id or "") or "-",
int(upstream_ts or 0),
str(emoji or "") or "-",
bool(remove),
)
bridge = transport.resolve_bridge_from_upstream(
user_id=user.id,
person_id=person_identifier.person_id,
service=source_service,
upstream_message_id=str(upstream_message_id or ""),
upstream_ts=int(upstream_ts or 0),
)
if not bridge:
bridge = await history.resolve_bridge_ref(
user=user,
identifier=person_identifier,
source_service=source_service,
upstream_message_id=str(upstream_message_id or ""),
upstream_author=str(actor or ""),
upstream_ts=int(upstream_ts or 0),
)
if not bridge:
self.log.warning(
"reaction-bridge external-resolve-miss source=%s user=%s person=%s upstream_id=%s upstream_ts=%s",
source_service,
user.id,
person_identifier.person_id,
str(upstream_message_id or "") or "-",
int(upstream_ts or 0),
)
return False
target_xmpp_id = str((bridge or {}).get("xmpp_message_id") or "").strip()
if not target_xmpp_id:
self.log.warning(
"reaction-bridge external-target-missing source=%s user=%s person=%s",
source_service,
user.id,
person_identifier.person_id,
)
return False
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_xmpp_reaction(
recipient_jid,
sender_jid,
target_xmpp_id=target_xmpp_id,
emoji=str(emoji or ""),
remove=bool(remove),
)
await history.apply_reaction(
user=user,
identifier=person_identifier,
target_message_id=str((bridge or {}).get("local_message_id") or ""),
target_ts=int((bridge or {}).get("upstream_ts") or 0),
emoji=str(emoji or ""),
source_service=source_service,
actor=str(actor or person_identifier.identifier),
remove=bool(remove),
payload=dict(payload or {}),
)
self.log.debug(
"reaction-bridge external-apply-ok source=%s user=%s person=%s xmpp_id=%s local_message_id=%s",
source_service,
user.id,
person_identifier.person_id,
target_xmpp_id,
str((bridge or {}).get("local_message_id") or "") or "-",
)
return True
async def send_chat_state(self, recipient_jid, sender_jid, started):
"""Send XMPP chat-state update to the client."""
@@ -1173,18 +1518,74 @@ class XMPPComponent(ComponentXMPP):
await self.send_chat_state(recipient_jid, sender_jid, started)
async def send_from_external(
self, user, person_identifier, text, is_outgoing_message, attachments=[]
self,
user,
person_identifier,
text,
is_outgoing_message,
attachments=[],
source_ref=None,
):
"""Handles sending XMPP messages with text and attachments."""
sender_jid = f"{person_identifier.person.name.lower()}|{person_identifier.service}@{settings.XMPP_JID}"
recipient_jid = f"{person_identifier.user.username}@{settings.XMPP_ADDRESS}"
if is_outgoing_message:
await self.send_xmpp_message(recipient_jid, sender_jid, f"YOU: {text}")
xmpp_id = await self.send_xmpp_message(
recipient_jid,
sender_jid,
f"YOU: {text}",
)
transport.record_bridge_mapping(
user_id=user.id,
person_id=person_identifier.person_id,
service=person_identifier.service,
xmpp_message_id=xmpp_id,
xmpp_ts=int(time.time() * 1000),
upstream_message_id=str((source_ref or {}).get("upstream_message_id") or ""),
upstream_author=str((source_ref or {}).get("upstream_author") or ""),
upstream_ts=int((source_ref or {}).get("upstream_ts") or 0),
text_preview=str(text or ""),
local_message_id=str((source_ref or {}).get("legacy_message_id") or ""),
)
await history.save_bridge_ref(
user=user,
identifier=person_identifier,
source_service=person_identifier.service,
local_message_id=str((source_ref or {}).get("legacy_message_id") or ""),
local_ts=int((source_ref or {}).get("xmpp_source_ts") or int(time.time() * 1000)),
xmpp_message_id=xmpp_id,
upstream_message_id=str((source_ref or {}).get("upstream_message_id") or ""),
upstream_author=str((source_ref or {}).get("upstream_author") or ""),
upstream_ts=int((source_ref or {}).get("upstream_ts") or 0),
)
# Step 1: Send text message separately
elif text:
await self.send_xmpp_message(recipient_jid, sender_jid, text)
xmpp_id = await self.send_xmpp_message(recipient_jid, sender_jid, text)
transport.record_bridge_mapping(
user_id=user.id,
person_id=person_identifier.person_id,
service=person_identifier.service,
xmpp_message_id=xmpp_id,
xmpp_ts=int(time.time() * 1000),
upstream_message_id=str((source_ref or {}).get("upstream_message_id") or ""),
upstream_author=str((source_ref or {}).get("upstream_author") or ""),
upstream_ts=int((source_ref or {}).get("upstream_ts") or 0),
text_preview=str(text or ""),
local_message_id=str((source_ref or {}).get("legacy_message_id") or ""),
)
await history.save_bridge_ref(
user=user,
identifier=person_identifier,
source_service=person_identifier.service,
local_message_id=str((source_ref or {}).get("legacy_message_id") or ""),
local_ts=int((source_ref or {}).get("xmpp_source_ts") or int(time.time() * 1000)),
xmpp_message_id=xmpp_id,
upstream_message_id=str((source_ref or {}).get("upstream_message_id") or ""),
upstream_author=str((source_ref or {}).get("upstream_author") or ""),
upstream_ts=int((source_ref or {}).get("upstream_ts") or 0),
)
if not attachments:
return [] # No attachments to process
@@ -1193,7 +1594,7 @@ class XMPPComponent(ComponentXMPP):
valid_uploads = await self.request_upload_slots(recipient_jid, attachments)
self.log.debug("Got upload slots")
if not valid_uploads:
self.log.warning("No valid upload slots obtained.")
self.log.debug("No valid upload slots obtained; attachment relay skipped")
return []
# Step 3: Upload each file and send its message immediately after upload
@@ -1201,8 +1602,33 @@ class XMPPComponent(ComponentXMPP):
self.upload_and_send(att, slot, recipient_jid, sender_jid)
for att, slot in valid_uploads
]
uploaded_urls = await asyncio.gather(*upload_tasks) # Upload files concurrently
return [url for url in uploaded_urls if url]
uploaded_rows = await asyncio.gather(*upload_tasks) # Upload files concurrently
normalized_rows = [dict(row or {}) for row in uploaded_rows if row]
for row in normalized_rows:
transport.record_bridge_mapping(
user_id=user.id,
person_id=person_identifier.person_id,
service=person_identifier.service,
xmpp_message_id=str(row.get("xmpp_message_id") or "").strip(),
xmpp_ts=int(time.time() * 1000),
upstream_message_id=str((source_ref or {}).get("upstream_message_id") or ""),
upstream_author=str((source_ref or {}).get("upstream_author") or ""),
upstream_ts=int((source_ref or {}).get("upstream_ts") or 0),
text_preview=str(row.get("url") or text or ""),
local_message_id=str((source_ref or {}).get("legacy_message_id") or ""),
)
await history.save_bridge_ref(
user=user,
identifier=person_identifier,
source_service=person_identifier.service,
local_message_id=str((source_ref or {}).get("legacy_message_id") or ""),
local_ts=int((source_ref or {}).get("xmpp_source_ts") or int(time.time() * 1000)),
xmpp_message_id=str(row.get("xmpp_message_id") or "").strip(),
upstream_message_id=str((source_ref or {}).get("upstream_message_id") or ""),
upstream_author=str((source_ref or {}).get("upstream_author") or ""),
upstream_ts=int((source_ref or {}).get("upstream_ts") or 0),
)
return [str(row.get("url") or "").strip() for row in normalized_rows if str(row.get("url") or "").strip()]
class XMPPClient(ClientBase):