Files
GIA/core/clients/xmpp.py
2026-03-08 22:08:55 +00:00

3040 lines
117 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import asyncio
import base64
import json
import logging
import os
import re
import time
import uuid
from pathlib import Path
from urllib.parse import urlsplit
import aiohttp
from asgiref.sync import sync_to_async
from django.conf import settings
from django.utils.timezone import now
from slixmpp.componentxmpp import ComponentXMPP
from slixmpp.plugins.xep_0085.stanza import Active, Composing, Gone, Inactive, Paused
from slixmpp.plugins.xep_0356.permissions import MessagePermission
from slixmpp.stanza import Message
from slixmpp.xmlstream import register_stanza_plugin
from slixmpp.xmlstream.handler import Callback
from slixmpp.xmlstream.matcher import StanzaPath
from slixmpp.xmlstream.stanzabase import ET
from core.clients import ClientBase, transport
from core.gateway.builtin import (
dispatch_builtin_gateway_command,
gateway_help_lines,
)
from core.messaging import ai, history, replies, reply_sync, utils
from core.models import (
ChatSession,
Manipulation,
PatternMitigationAutoSettings,
PatternMitigationCorrection,
PatternMitigationGame,
PatternMitigationPlan,
PatternMitigationRule,
Person,
PersonIdentifier,
User,
UserXmppOmemoState,
WorkspaceConversation,
)
from core.security.attachments import (
validate_attachment_metadata,
validate_attachment_url,
)
from core.util import logs
URL_PATTERN = re.compile(r"https?://[^\s<>'\"\\]+")
SIGNAL_UUID_PATTERN = re.compile(
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
re.IGNORECASE,
)
EMOJI_ONLY_PATTERN = re.compile(
r"^[\U0001F300-\U0001FAFF\u2600-\u27BF\uFE0F\u200D\u2640-\u2642\u2764]+$"
)
TOTP_BASE32_SECRET_RE = re.compile(r"^[A-Z2-7]{16,}$")
PUBSUB_NS = "http://jabber.org/protocol/pubsub"
OMEMO_OLD_NS = "eu.siacs.conversations.axolotl"
OMEMO_OLD_DEVICELIST_NODE = "eu.siacs.conversations.axolotl.devicelist"
XMPP_LOCALPART_ESCAPE_MAP = {
"\\": r"\5c",
" ": r"\20",
'"': r"\22",
"&": r"\26",
"'": r"\27",
"/": r"\2f",
":": r"\3a",
"<": r"\3c",
">": r"\3e",
"@": r"\40",
"|": r"\7c",
}
XMPP_LOCALPART_UNESCAPE_MAP = {
value: key for key, value in XMPP_LOCALPART_ESCAPE_MAP.items()
}
XMPP_LOCALPART_UNESCAPE_RE = re.compile(
"|".join(
sorted(
(re.escape(key) for key in XMPP_LOCALPART_UNESCAPE_MAP.keys()),
key=len,
reverse=True,
)
)
)
def _clean_url(value):
return str(value or "").strip().rstrip(".,);:!?\"'")
def _escape_xmpp_localpart(value):
return "".join(XMPP_LOCALPART_ESCAPE_MAP.get(ch, ch) for ch in str(value or ""))
def _unescape_xmpp_localpart(value):
text = str(value or "")
if not text:
return ""
return XMPP_LOCALPART_UNESCAPE_RE.sub(
lambda match: XMPP_LOCALPART_UNESCAPE_MAP.get(match.group(0), match.group(0)),
text,
)
def _normalized_person_lookup_token(value):
return re.sub(r"[^a-z0-9]+", "", str(value or "").strip().lower())
def _resolve_person_from_xmpp_localpart(*, user, localpart_value):
raw_value = _unescape_xmpp_localpart(localpart_value).strip()
if not raw_value:
return None
# First try a straightforward case-insensitive name match.
person = Person.objects.filter(user=user, name__iexact=raw_value).first()
if person is not None:
return person
# Fall back to normalized matching so `test-account`, `test_account`,
# and `test account` can resolve the same contact.
normalized_target = _normalized_person_lookup_token(raw_value)
if not normalized_target:
return None
for candidate in Person.objects.filter(user=user).only("id", "name"):
if _normalized_person_lookup_token(candidate.name) == normalized_target:
return candidate
return None
def _signal_identifier_rank(identifier_value):
identifier_text = str(identifier_value or "").strip()
if not identifier_text:
return 99
if identifier_text.startswith("group."):
return 0
if identifier_text.startswith("+"):
return 1
if SIGNAL_UUID_PATTERN.fullmatch(identifier_text):
return 2
return 3
def _select_person_identifier(*, user, person, service=None):
rows = list(
PersonIdentifier.objects.filter(
user=user,
person=person,
**({"service": service} if service else {}),
).order_by("id")
)
if not rows:
return None
if service == "signal" and len(rows) > 1:
rows.sort(key=lambda row: (_signal_identifier_rank(row.identifier), row.id))
chosen = rows[0]
if len(rows) > 1:
logging.getLogger(__name__).warning(
"Resolved multiple identifiers for user=%s person=%s service=%s; choosing %s from %s rows",
getattr(user, "id", None),
getattr(person, "id", None),
service or getattr(chosen, "service", ""),
getattr(chosen, "identifier", ""),
len(rows),
)
return chosen
def _filename_from_url(url_value):
path = urlsplit(str(url_value or "")).path
name = path.rsplit("/", 1)[-1]
return name or "attachment"
def _content_type_from_filename_or_url(url_value, default="application/octet-stream"):
_ = url_value
return str(default or "application/octet-stream")
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
def _attachment_rows_from_body_urls(body_text):
rows = []
seen = set()
for raw_line in str(body_text or "").splitlines():
candidate = _clean_url(raw_line)
if not candidate:
continue
try:
safe_url = validate_attachment_url(candidate)
filename, content_type = validate_attachment_metadata(
filename=_filename_from_url(safe_url),
content_type=_content_type_from_filename_or_url(safe_url),
)
except Exception:
continue
if safe_url in seen:
continue
seen.add(safe_url)
rows.append(
{
"url": safe_url,
"filename": filename,
"content_type": content_type,
}
)
return rows
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}
def _omemo_plugin_available() -> bool:
try:
import importlib
return importlib.util.find_spec("slixmpp_omemo") is not None
except Exception:
return False
def _extract_sender_omemo_client_key(stanza) -> dict:
"""Extract OMEMO client key info from an encrypted stanza."""
ns = "eu.siacs.conversations.axolotl"
header = stanza.xml.find(f".//{{{ns}}}header")
if header is None:
return {"status": "no_omemo"}
sid = str(header.attrib.get("sid") or "").strip()
key_el = header.find(f"{{{ns}}}key")
rid = str(key_el.attrib.get("rid") or "").strip() if key_el is not None else ""
if sid or rid:
return {"status": "detected", "client_key": f"sid:{sid},rid:{rid}"}
return {"status": "no_omemo"}
def _format_omemo_identity_fingerprint(identity_key) -> str:
if isinstance(identity_key, (bytes, bytearray)):
key_bytes = bytes(identity_key)
else:
return ""
try:
from omemo.session_manager import SessionManager as _OmemoSessionManager
return " ".join(_OmemoSessionManager.format_identity_key(key_bytes)).upper()
except Exception:
return ":".join(f"{b:02X}" for b in key_bytes)
# ---------------------------------------------------------------------------
# OMEMO storage + plugin implementation
# ---------------------------------------------------------------------------
try:
from omemo.storage import Just, Maybe, Nothing
from omemo.storage import Storage as _OmemoStorageBase
from slixmpp.plugins.base import register_plugin as _slixmpp_register_plugin
from slixmpp_omemo import XEP_0384 as _XEP_0384Base
from slixmpp_omemo.base_session_manager import TrustLevel as _OmemoTrustLevel
_OMEMO_AVAILABLE = True
except ImportError:
_OMEMO_AVAILABLE = False
_OmemoStorageBase = object
_XEP_0384Base = object
_OmemoTrustLevel = None
_slixmpp_register_plugin = None
if _OMEMO_AVAILABLE:
class _OmemoStorage(_OmemoStorageBase):
"""JSON-file-backed OMEMO key storage."""
def __init__(self, path: str) -> None:
super().__init__()
self._path = path
try:
with open(path) as f:
self._data: dict = json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
self._data = {}
def _save(self) -> None:
os.makedirs(os.path.dirname(self._path), exist_ok=True)
with open(self._path, "w") as f:
json.dump(self._data, f)
async def _load(self, key: str) -> Maybe:
if key in self._data:
return Just(self._data[key])
return Nothing()
async def _store(self, key: str, value) -> None:
self._data[key] = value
self._save()
async def _delete(self, key: str) -> None:
self._data.pop(key, None)
self._save()
class _GiaOmemoPlugin(_XEP_0384Base):
"""Concrete XEP-0384 OMEMO plugin for the GIA XMPP gateway component.
Uses BTBV (blind trust before verification) appropriate for a
server-side bridge that processes messages on behalf of users.
"""
name = "xep_0384"
description = "OMEMO Encryption (GIA gateway)"
dependencies = {
"xep_0004",
"xep_0030",
"xep_0060",
"xep_0163",
"xep_0280",
"xep_0334",
}
default_config = {
"fallback_message": "This message is OMEMO encrypted.",
"data_dir": "",
}
def plugin_init(self) -> None:
data_dir = str(self.config.get("data_dir") or "").strip()
if not data_dir:
data_dir = str(Path(settings.BASE_DIR) / "xmpp_omemo_data")
os.makedirs(data_dir, exist_ok=True)
self._storage_impl = _OmemoStorage(os.path.join(data_dir, "omemo.json"))
super().plugin_init()
@property
def storage(self) -> _OmemoStorageBase:
return self._storage_impl
@property
def _btbv_enabled(self) -> bool:
return True
async def _devices_blindly_trusted(self, blindly_trusted, identifier):
import logging
logging.getLogger(__name__).info(
"OMEMO: blindly trusted %d new device(s)", len(blindly_trusted)
)
async def _prompt_manual_trust(self, manually_trusted, identifier):
"""Auto-trust all undecided devices (gateway mode)."""
import logging
log = logging.getLogger(__name__)
log.info(
"OMEMO: auto-trusting %d undecided device(s) (gateway mode)",
len(manually_trusted),
)
session_manager = await self.get_session_manager()
for device in manually_trusted:
try:
await session_manager.set_trust(
device.bare_jid,
device.device_id,
device.identity_key,
_OmemoTrustLevel.BLINDLY_TRUSTED.value,
)
except Exception as exc:
log.warning(
"OMEMO set_trust failed for %s: %s", device.bare_jid, exc
)
class XMPPComponent(ComponentXMPP):
"""
A simple Slixmpp component that echoes messages.
"""
def __init__(self, ur, jid, secret, server, port):
self.ur = ur
self._upload_config_warned = False
self._reconnect_task = None
self._reconnect_delay_seconds = 1.0
self._reconnect_delay_max_seconds = 30.0
self._connect_inflight = False
self._session_live = False
self.log = logs.get_logger("XMPP")
logging.getLogger("slixmpp_omemo").setLevel(logging.DEBUG)
super().__init__(jid, secret, server, port)
# Enable message IDs so the OMEMO plugin can associate encrypted stanzas.
self.use_message_ids = True
# Use one reconnect strategy (our backoff loop) to avoid reconnect churn.
self.auto_reconnect = False
# Register chat state plugins
register_stanza_plugin(Message, Active)
register_stanza_plugin(Message, Composing)
register_stanza_plugin(Message, Paused)
register_stanza_plugin(Message, Inactive)
register_stanza_plugin(Message, Gone)
self.add_event_handler("session_start", self.session_start)
self.add_event_handler("disconnected", self.on_disconnected)
self.add_event_handler("message", self.message)
self.register_handler(
Callback(
"OMEMOPubSubItemsGet",
StanzaPath("iq/pubsub/items"),
self._handle_pubsub_iq_get,
)
)
self.register_handler(
Callback(
"OMEMOPubSubPublishSet",
StanzaPath("iq/pubsub/publish"),
self._handle_pubsub_iq_set,
)
)
# Presence event handlers
self.add_event_handler("presence_available", self.on_presence_available)
self.add_event_handler("presence_dnd", self.on_presence_dnd)
self.add_event_handler("presence_xa", self.on_presence_xa)
self.add_event_handler("presence_chat", self.on_presence_chat)
self.add_event_handler("presence_away", self.on_presence_away)
self.add_event_handler("presence_unavailable", self.on_presence_unavailable)
self.add_event_handler("presence_subscribe", self.on_presence_subscribe)
self.add_event_handler("presence_subscribed", self.on_presence_subscribed)
self.add_event_handler("presence_unsubscribe", self.on_presence_unsubscribe)
self.add_event_handler("presence_unsubscribed", self.on_presence_unsubscribed)
self.add_event_handler(
"roster_subscription_request", self.on_roster_subscription_request
)
self.add_event_handler("privileges_advertised", self.on_privileges_advertised)
# Chat state handlers
self.add_event_handler("chatstate_active", self.on_chatstate_active)
self.add_event_handler("chatstate_composing", self.on_chatstate_composing)
self.add_event_handler("chatstate_paused", self.on_chatstate_paused)
self.add_event_handler("chatstate_inactive", self.on_chatstate_inactive)
self.add_event_handler("chatstate_gone", self.on_chatstate_gone)
self._omemo_component_pubsub = self._seed_component_omemo_pubsub()
@staticmethod
def _clone_xml(node):
return ET.fromstring(ET.tostring(node, encoding="unicode"))
def _seed_component_omemo_pubsub(self):
return {}
def _sync_component_omemo_pubsub(self, device_id, identity_key):
device_id = str(device_id or "").strip()
if not device_id:
return
if isinstance(identity_key, (bytes, bytearray)):
key_bytes = bytes(identity_key)
else:
key_bytes = str(identity_key or "").encode()
identity_b64 = base64.b64encode(key_bytes).decode()
devicelist = ET.Element(f"{{{OMEMO_OLD_NS}}}list")
dev = ET.SubElement(devicelist, f"{{{OMEMO_OLD_NS}}}device")
dev.set("id", device_id)
bundle = ET.Element(f"{{{OMEMO_OLD_NS}}}bundle")
ik = ET.SubElement(bundle, f"{{{OMEMO_OLD_NS}}}identityKey")
ik.text = identity_b64
self._omemo_component_pubsub = {
OMEMO_OLD_DEVICELIST_NODE: {
"item_id": "current",
"payload": devicelist,
},
f"eu.siacs.conversations.axolotl.bundles:{device_id}": {
"item_id": "current",
"payload": bundle,
},
}
async def _reply_pubsub_items(self, iq, node_name):
reply = iq.reply()
pubsub = ET.Element(f"{{{PUBSUB_NS}}}pubsub")
items = ET.SubElement(pubsub, f"{{{PUBSUB_NS}}}items")
items.set("node", node_name)
stored = self._omemo_component_pubsub.get(node_name)
if stored is None and node_name.startswith(
"eu.siacs.conversations.axolotl.bundles:"
):
bundle_nodes = [
key
for key in self._omemo_component_pubsub
if key.startswith("eu.siacs.conversations.axolotl.bundles:")
]
if len(bundle_nodes) == 1:
stored = self._omemo_component_pubsub.get(bundle_nodes[0])
if stored:
item = ET.SubElement(items, f"{{{PUBSUB_NS}}}item")
item.set("id", str(stored.get("item_id") or "current"))
payload = stored.get("payload")
if payload is not None:
item.append(self._clone_xml(payload))
reply.append(pubsub)
await reply.send()
async def on_iq_get(self, iq):
if iq["type"] != "get":
return
try:
iq_to = str(iq["to"] or "").split("/", 1)[0].strip().lower()
except Exception:
iq_to = ""
own_jid = str(getattr(self.boundjid, "bare", "") or "").strip().lower()
if iq_to and own_jid and iq_to != own_jid:
return
items = iq.xml.find(f".//{{{PUBSUB_NS}}}items")
if items is None:
return
node_name = str(items.attrib.get("node") or "").strip()
if not node_name:
return
self.log.debug(
"OMEMO IQ-GET pubsub items node=%s from=%s to=%s",
node_name,
iq.get("from"),
iq.get("to"),
)
if (
node_name.startswith("eu.siacs.conversations.axolotl.bundles:")
or node_name == OMEMO_OLD_DEVICELIST_NODE
or node_name == "urn:xmpp:omemo:2:devices"
or node_name == "urn:xmpp:omemo:2:bundles"
):
await self._reply_pubsub_items(iq, node_name)
async def on_iq_set(self, iq):
if iq["type"] != "set":
return
try:
iq_to = str(iq["to"] or "").split("/", 1)[0].strip().lower()
except Exception:
iq_to = ""
own_jid = str(getattr(self.boundjid, "bare", "") or "").strip().lower()
if iq_to and own_jid and iq_to != own_jid:
return
publish = iq.xml.find(f".//{{{PUBSUB_NS}}}publish")
if publish is None:
return
node_name = str(publish.attrib.get("node") or "").strip()
if not node_name:
return
self.log.debug(
"OMEMO IQ-SET pubsub publish node=%s from=%s to=%s",
node_name,
iq.get("from"),
iq.get("to"),
)
item = publish.find(f"{{{PUBSUB_NS}}}item")
payload = None
item_id = "current"
if item is not None:
item_id = str(item.attrib.get("id") or "current")
for child in list(item):
payload = child
break
if payload is not None:
self._omemo_component_pubsub[node_name] = {
"item_id": item_id,
"payload": self._clone_xml(payload),
}
await iq.reply().send()
def _handle_pubsub_iq_get(self, iq):
asyncio.create_task(self.on_iq_get(iq))
def _handle_pubsub_iq_set(self, iq):
asyncio.create_task(self.on_iq_set(iq))
def _user_xmpp_domain(self):
domain = str(getattr(settings, "XMPP_USER_DOMAIN", "") or "").strip()
if domain:
return domain
component_jid = str(getattr(settings, "XMPP_JID", "") or "").strip()
if "." in component_jid:
return component_jid.split(".", 1)[1]
configured_domain = str(getattr(settings, "DOMAIN", "") or "").strip()
if configured_domain:
return configured_domain
return str(getattr(settings, "XMPP_ADDRESS", "") or "").strip()
def _user_jid(self, username):
return f"{username}@{self._user_xmpp_domain()}"
def _contact_component_jid(self, person_name: str, service: str) -> str:
escaped_name = _escape_xmpp_localpart(str(person_name or "").strip().lower())
return f"{escaped_name}|{str(service or '').strip().lower()}@{self.boundjid.bare}"
def _build_privileged_outbound_message(
self, *, user_jid, contact_jid, body_text, attachment_url=None
):
msg = self.make_message(mto=contact_jid, mfrom=user_jid, mtype="chat")
if not msg.get("id"):
msg["id"] = uuid.uuid4().hex
msg["body"] = str(body_text or "")
if attachment_url:
oob_element = ET.Element("{jabber:x:oob}x")
url_element = ET.SubElement(oob_element, "{jabber:x:oob}url")
url_element.text = str(attachment_url)
msg.xml.append(oob_element)
return msg
def _can_send_privileged_messages(self, user_jid: str) -> bool:
domain = str(user_jid.split("@", 1)[1] if "@" in user_jid else "").strip()
if not domain:
return False
configured_domain = self._user_xmpp_domain()
if configured_domain and domain == configured_domain:
return True
plugin = self.plugin.get("xep_0356", None)
if plugin is None:
return False
perms = plugin.granted_privileges.get(domain)
if perms is None:
return False
return perms.message == MessagePermission.OUTGOING
async def send_sent_carbon_copy(
self, *, user_jid, contact_jid, body_text, attachment_url=None
) -> bool:
if not self._can_send_privileged_messages(user_jid):
self.log.debug(
"Skipping sent carbon copy for %s: outgoing message privilege unavailable",
user_jid,
)
return False
plugin = self.plugin.get("xep_0356", None)
if plugin is None:
return False
try:
msg = self._build_privileged_outbound_message(
user_jid=user_jid,
contact_jid=contact_jid,
body_text=body_text,
attachment_url=attachment_url,
)
domain = str(user_jid.split("@", 1)[1] if "@" in user_jid else "").strip()
perms = plugin.granted_privileges.get(domain) if domain else None
if perms is not None and perms.message == MessagePermission.OUTGOING:
plugin.send_privileged_message(msg)
else:
plugin._make_privileged_message(msg).send()
self.log.debug(
"Sent privileged carbon copy user=%s contact=%s",
user_jid,
contact_jid,
)
return True
except Exception as exc:
self.log.warning(
"Failed to send privileged carbon copy user=%s contact=%s: %s",
user_jid,
contact_jid,
exc,
)
return False
async def enable_carbons(self):
"""Enable XMPP Message Carbons (XEP-0280)"""
try:
iq = self.make_iq_set()
iq["enable"] = ET.Element("{urn:xmpp:carbons:2}enable")
await iq.send()
self.log.info("Message Carbons enabled successfully")
except Exception as e:
self.log.error(f"Failed to enable Carbons: {e}")
def get_identifier(self, msg):
# Extract sender JID (full format: user@domain/resource)
sender_jid = str(msg["from"])
# Split into username@domain and optional resource
sender_parts = sender_jid.split("/", 1)
sender_bare_jid = sender_parts[0] # Always present: user@domain
sender_username, sender_domain = sender_bare_jid.split("@", 1)
# Extract recipient JID (should match component JID format)
recipient_jid = str(msg["to"])
if "@" in recipient_jid:
recipient_username = recipient_jid.split("@", 1)[0]
else:
recipient_username = recipient_jid
# Parse recipient_name and recipient_service (e.g., "mark|signal")
if "|" in recipient_username:
person_name, service = recipient_username.split("|", 1)
person_name = person_name.strip()
service = service.strip().lower()
else:
person_name = recipient_username.strip()
service = None
try:
# Lookup user in Django
self.log.debug("Resolving XMPP sender user=%s", sender_username)
user = User.objects.get(username=sender_username)
self.log.debug("Resolving XMPP recipient person=%s", person_name)
person = _resolve_person_from_xmpp_localpart(
user=user, localpart_value=person_name
)
if person is None:
raise Person.DoesNotExist(f"No person found for '{person_name}'")
# Ensure a PersonIdentifier exists for this user, person, and service
self.log.debug("Resolving XMPP identifier service=%s", service)
if service:
identifier = _select_person_identifier(
user=user,
person=person,
service=service,
)
if identifier is None:
raise PersonIdentifier.DoesNotExist(
f"No identifier found for person '{person_name}'"
)
else:
identifier = _select_person_identifier(user=user, person=person)
if identifier is None:
raise PersonIdentifier.DoesNotExist(
f"No identifier found for person '{person_name}'"
)
return identifier
except Exception as e:
self.log.error(f"Failed to resolve identifier from XMPP message: {e}")
return None
def _get_workspace_conversation(self, user, person):
primary_identifier = (
PersonIdentifier.objects.filter(user=user, person=person)
.order_by("service")
.first()
)
platform_type = primary_identifier.service if primary_identifier else "signal"
conversation, _ = WorkspaceConversation.objects.get_or_create(
user=user,
platform_type=platform_type,
title=f"{person.name} Workspace",
defaults={"platform_thread_id": str(person.id)},
)
conversation.participants.add(person)
return conversation
def _get_or_create_plan(self, user, person):
conversation = self._get_workspace_conversation(user, person)
plan = conversation.mitigation_plans.order_by("-updated_at").first()
if plan is None:
plan = PatternMitigationPlan.objects.create(
user=user,
conversation=conversation,
title=f"{person.name} Pattern Mitigation",
objective="Mitigate repeated friction loops.",
fundamental_items=[],
creation_mode="guided",
status="draft",
)
PatternMitigationRule.objects.create(
user=user,
plan=plan,
title="Safety Before Analysis",
content="Prioritize de-escalation before analysis.",
enabled=True,
)
PatternMitigationGame.objects.create(
user=user,
plan=plan,
title="Two-Turn Pause",
instructions="Use two short turns then pause with a return time.",
enabled=True,
)
return plan
def _derived_omemo_fingerprint(self, jid: str) -> str:
import hashlib
return hashlib.sha256(f"xmpp-omemo-key:{jid}".encode()).hexdigest()[:32]
def _get_omemo_plugin(self):
"""Return the active XEP-0384 plugin instance, or None if not loaded."""
try:
return self["xep_0384"]
except Exception:
return None
async def _bootstrap_omemo_for_authentic_channel(self):
jid = str(getattr(settings, "XMPP_JID", "") or "").strip()
omemo_plugin = self._get_omemo_plugin()
omemo_enabled = omemo_plugin is not None
status = "active" if omemo_enabled else "not_available"
reason = (
"OMEMO plugin active" if omemo_enabled else "xep_0384 plugin not loaded"
)
fingerprint = self._derived_omemo_fingerprint(jid)
if omemo_enabled:
try:
import asyncio as _asyncio
# OMEMO session-manager bootstrap can take longer in component mode because
# it performs consistency checks and initial pubsub interactions before
# device publication is complete.
session_manager = await _asyncio.wait_for(
omemo_plugin.get_session_manager(), timeout=180.0
)
own_devices = await session_manager.get_own_device_information()
device_id = None
if own_devices:
# own_devices is a tuple: (own_device, other_devices)
own_device = (
own_devices[0]
if isinstance(own_devices, (tuple, list))
else own_devices
)
key_bytes = own_device.identity_key
try:
from omemo.session_manager import (
SessionManager as _OmemoSessionManager,
)
fingerprint = " ".join(
_OmemoSessionManager.format_identity_key(key_bytes)
).upper()
except Exception:
fingerprint = ":".join(f"{b:02X}" for b in key_bytes)
device_id = own_device.device_id
self._sync_component_omemo_pubsub(device_id, key_bytes)
self.log.info(
"OMEMO: own device created, device_id=%s fingerprint=%s",
device_id,
fingerprint,
)
else:
# Fallback: session manager may not auto-create devices for component JIDs
# Manually generate a device ID and use it
import random
device_id = random.randint(1, 2**31 - 1)
self.log.warning(
"OMEMO: session manager did not create device (component JID limitation), "
"using fallback device_id=%s",
device_id,
)
# CRITICAL FIX: Publish BOTH device list AND device bundle
# Clients need both:
# 1. Device list node: eu.siacs.conversations.axolotl/devices/{jid} (lists device IDs)
# 2. Device bundle nodes: eu.siacs.conversations.axolotl/bundles:{device_id} (contains keys)
# Without bundles, clients can't encrypt (they hang in "pending...")
if device_id:
try:
namespace = "eu.siacs.conversations.axolotl"
pubsub_service = (
str(getattr(settings, "XMPP_PUBSUB_SERVICE", "pubsub"))
or "pubsub"
)
# Step 1: Publish device list
try:
device_list_dict = {device_id: None}
await session_manager._upload_device_list(
namespace, device_list_dict
)
self.log.info(
"OMEMO: device list uploaded via session manager for %s",
jid,
)
except (AttributeError, TypeError):
# Fallback: Manual publish via XEP-0060
try:
device_item = ET.Element("list")
device_item.set("xmlns", namespace)
for dev_id in device_list_dict:
device_elem = ET.SubElement(device_item, "device")
device_elem.set("id", str(dev_id))
node_name = f"{namespace}/devices/{jid}"
await self["xep_0060"].publish(
pubsub_service, node_name, payload=device_item
)
self.log.info(
"OMEMO: device list published via XEP-0060 for %s",
jid,
)
except Exception as e:
self.log.warning(
"OMEMO: device list publish failed: %s", e
)
# Step 2: Publish device bundle (CRITICAL - contains the keys!)
# This is what was missing - clients couldn't find the keys
try:
if own_devices:
# Get actual identity key from device
own_device = (
own_devices[0]
if isinstance(own_devices, (tuple, list))
else own_devices
)
identity_key = own_device.identity_key
signed_prekey = getattr(
own_device, "signed_prekey", None
)
# Build bundle item with actual keys
bundle_item = ET.Element("bundle")
bundle_item.set("xmlns", namespace)
# Add signed prekey
if signed_prekey:
spk_elem = ET.SubElement(
bundle_item, "signedPreKeyPublic"
)
spk_elem.text = signed_prekey
else:
# Fallback: use hash of identity key
import base64
spk_elem = ET.SubElement(
bundle_item, "signedPreKeyPublic"
)
spk_elem.text = base64.b64encode(
identity_key
).decode()
# Add identity key
ik_elem = ET.SubElement(bundle_item, "identityKey")
import base64
ik_elem.text = base64.b64encode(identity_key).decode()
# Publish bundle
bundle_node = f"{namespace}/bundles:{device_id}"
await self["xep_0060"].publish(
pubsub_service, bundle_node, payload=bundle_item
)
self.log.info(
"OMEMO: device bundle published for %s (device_id=%s)",
jid,
device_id,
)
except Exception as bundle_exc:
self.log.warning(
"OMEMO: device bundle publish failed: %s", bundle_exc
)
except Exception as upload_exc:
self.log.warning(
"OMEMO: device/bundle upload error: %s", upload_exc
)
# Try to refresh device list to ensure all devices are properly registered.
try:
await session_manager.refresh_device_lists(jid)
self.log.info("OMEMO: device list refreshed for %s", jid)
except Exception as refresh_exc:
self.log.debug("OMEMO: device list refresh: %s", refresh_exc)
except _asyncio.TimeoutError:
self.log.error(
"OMEMO: session manager initialization timeout after 180s. "
"Device list may not be published to PubSub. "
"Clients will not be able to discover gateway devices. "
"Check PubSub server connectivity and latency."
)
status = "timeout"
reason = (
"Session manager initialization timeout - device list not published"
)
except Exception as exc:
self.log.warning(
"OMEMO: could not initialize device information: %s", exc
)
self.log.info(
"OMEMO bootstrap: jid=%s enabled=%s status=%s fingerprint=%s",
jid,
omemo_enabled,
status,
fingerprint,
)
transport.update_runtime_state(
"xmpp",
omemo_target_jid=jid,
omemo_fingerprint=fingerprint,
omemo_enabled=omemo_enabled,
omemo_status=status,
omemo_status_reason=reason,
)
async def _record_sender_omemo_state(
self,
user,
*,
sender_jid,
recipient_jid,
message_stanza,
sender_fingerprint="",
):
parsed = _extract_sender_omemo_client_key(message_stanza)
status = str(parsed.get("status") or "no_omemo")
client_key = str(parsed.get("client_key") or "")
def _save_row():
row, _ = UserXmppOmemoState.objects.get_or_create(user=user)
details = dict(row.details or {})
if sender_fingerprint:
details["latest_client_fingerprint"] = str(sender_fingerprint)
row.status = status
row.latest_client_key = client_key
row.last_sender_jid = str(sender_jid or "")
row.last_target_jid = str(recipient_jid or "")
row.details = details
row.save(
update_fields=[
"status",
"latest_client_key",
"last_sender_jid",
"last_target_jid",
"details",
"updated_at",
]
)
await sync_to_async(_save_row)()
async def _route_gateway_command(
self,
*,
sender_user,
body,
service,
channel_identifier,
sender_identifier,
sender_jid,
recipient_jid,
local_message,
message_meta,
sym,
):
return await dispatch_builtin_gateway_command(
user=sender_user,
command_text=str(body or "").strip(),
service=str(service or "xmpp"),
channel_identifier=str(channel_identifier or sender_jid or ""),
sender_identifier=str(sender_identifier or sender_jid or ""),
source_message=local_message,
message_meta=dict(message_meta or {}),
payload={
"sender_jid": str(sender_jid or ""),
"recipient_jid": str(recipient_jid or ""),
},
emit=sym,
)
async def execute_gateway_command(
self,
*,
sender_user,
body,
service,
channel_identifier,
sender_identifier,
local_message=None,
message_meta=None,
sender_jid="",
recipient_jid="",
) -> list[str]:
captured_replies: list[str] = []
def _capture(value):
text_value = str(value or "").strip()
if text_value:
captured_replies.append(text_value)
await self._route_gateway_command(
sender_user=sender_user,
body=body,
service=service,
channel_identifier=channel_identifier,
sender_identifier=sender_identifier,
sender_jid=sender_jid,
recipient_jid=recipient_jid,
local_message=local_message,
message_meta=dict(message_meta or {}),
sym=_capture,
)
return captured_replies
def _gateway_help_lines(self):
return gateway_help_lines()
async def _handle_mitigation_command(self, sender_user, body, sym):
def parse_parts(raw):
return [part.strip() for part in raw.split("|")]
command = body.strip()
if command == ".mitigation help":
sym(
"Mitigation commands: "
".mitigation list | "
".mitigation show <person> | "
".mitigation rule-add <person>|<title>|<content> | "
".mitigation rule-del <person>|<title> | "
".mitigation game-add <person>|<title>|<instructions> | "
".mitigation game-del <person>|<title> | "
".mitigation correction-add <person>|<title>|<clarification> | "
".mitigation correction-del <person>|<title> | "
".mitigation fundamentals-set <person>|<item1;item2;...> | "
".mitigation plan-set <person>|<draft|active|archived>|<auto|guided> | "
".mitigation auto <person>|on|off | "
".mitigation auto-status <person>"
)
return True
if command == ".mitigation list":
plans = await sync_to_async(list)(
PatternMitigationPlan.objects.filter(user=sender_user)
.select_related("conversation")
.order_by("-updated_at")[:15]
)
if not plans:
sym("No mitigation plans found.")
return True
rows = []
for plan in plans:
person_name = (
plan.conversation.participants.order_by("name").first().name
if plan.conversation.participants.exists()
else "Unknown"
)
rows.append(f"{person_name}: {plan.title}")
sym("Plans: " + " | ".join(rows))
return True
if command.startswith(".mitigation show "):
person_name = command.replace(".mitigation show ", "", 1).strip().title()
person = await sync_to_async(
lambda: Person.objects.filter(
user=sender_user, name__iexact=person_name
).first()
)()
if not person:
sym("Unknown person.")
return True
plan = await sync_to_async(self._get_or_create_plan)(sender_user, person)
rule_count = await sync_to_async(plan.rules.count)()
game_count = await sync_to_async(plan.games.count)()
sym(f"{person.name}: {plan.title} | rules={rule_count} games={game_count}")
return True
if command.startswith(".mitigation rule-add "):
payload = command.replace(".mitigation rule-add ", "", 1)
parts = parse_parts(payload)
if len(parts) < 3:
sym("Usage: .mitigation rule-add <person>|<title>|<content>")
return True
person_name, title, content = (
parts[0].title(),
parts[1],
"|".join(parts[2:]),
)
person = await sync_to_async(
lambda: Person.objects.filter(
user=sender_user, name__iexact=person_name
).first()
)()
if not person:
sym("Unknown person.")
return True
plan = await sync_to_async(self._get_or_create_plan)(sender_user, person)
await sync_to_async(PatternMitigationRule.objects.create)(
user=sender_user,
plan=plan,
title=title[:255],
content=content,
enabled=True,
)
sym("Rule added.")
return True
if command.startswith(".mitigation rule-del "):
payload = command.replace(".mitigation rule-del ", "", 1)
parts = parse_parts(payload)
if len(parts) < 2:
sym("Usage: .mitigation rule-del <person>|<title>")
return True
person_name, title = parts[0].title(), "|".join(parts[1:])
person = await sync_to_async(
lambda: Person.objects.filter(
user=sender_user, name__iexact=person_name
).first()
)()
if not person:
sym("Unknown person.")
return True
plan = await sync_to_async(self._get_or_create_plan)(sender_user, person)
deleted, _ = await sync_to_async(
lambda: PatternMitigationRule.objects.filter(
user=sender_user,
plan=plan,
title__iexact=title,
).delete()
)()
sym("Rule deleted." if deleted else "Rule not found.")
return True
if command.startswith(".mitigation game-add "):
payload = command.replace(".mitigation game-add ", "", 1)
parts = parse_parts(payload)
if len(parts) < 3:
sym("Usage: .mitigation game-add <person>|<title>|<instructions>")
return True
person_name, title, content = (
parts[0].title(),
parts[1],
"|".join(parts[2:]),
)
person = await sync_to_async(
lambda: Person.objects.filter(
user=sender_user, name__iexact=person_name
).first()
)()
if not person:
sym("Unknown person.")
return True
plan = await sync_to_async(self._get_or_create_plan)(sender_user, person)
await sync_to_async(PatternMitigationGame.objects.create)(
user=sender_user,
plan=plan,
title=title[:255],
instructions=content,
enabled=True,
)
sym("Game added.")
return True
if command.startswith(".mitigation game-del "):
payload = command.replace(".mitigation game-del ", "", 1)
parts = parse_parts(payload)
if len(parts) < 2:
sym("Usage: .mitigation game-del <person>|<title>")
return True
person_name, title = parts[0].title(), "|".join(parts[1:])
person = await sync_to_async(
lambda: Person.objects.filter(
user=sender_user, name__iexact=person_name
).first()
)()
if not person:
sym("Unknown person.")
return True
plan = await sync_to_async(self._get_or_create_plan)(sender_user, person)
deleted, _ = await sync_to_async(
lambda: PatternMitigationGame.objects.filter(
user=sender_user,
plan=plan,
title__iexact=title,
).delete()
)()
sym("Game deleted." if deleted else "Game not found.")
return True
if command.startswith(".mitigation correction-add "):
payload = command.replace(".mitigation correction-add ", "", 1)
parts = parse_parts(payload)
if len(parts) < 3:
sym(
"Usage: .mitigation correction-add <person>|<title>|<clarification>"
)
return True
person_name, title, clarification = (
parts[0].title(),
parts[1],
"|".join(parts[2:]),
)
person = await sync_to_async(
lambda: Person.objects.filter(
user=sender_user, name__iexact=person_name
).first()
)()
if not person:
sym("Unknown person.")
return True
plan = await sync_to_async(self._get_or_create_plan)(sender_user, person)
await sync_to_async(PatternMitigationCorrection.objects.create)(
user=sender_user,
plan=plan,
title=title[:255],
clarification=clarification,
source_phrase="",
perspective="second_person",
share_target="both",
language_style="adapted",
enabled=True,
)
sym("Correction added.")
return True
if command.startswith(".mitigation correction-del "):
payload = command.replace(".mitigation correction-del ", "", 1)
parts = parse_parts(payload)
if len(parts) < 2:
sym("Usage: .mitigation correction-del <person>|<title>")
return True
person_name, title = parts[0].title(), "|".join(parts[1:])
person = await sync_to_async(
lambda: Person.objects.filter(
user=sender_user, name__iexact=person_name
).first()
)()
if not person:
sym("Unknown person.")
return True
plan = await sync_to_async(self._get_or_create_plan)(sender_user, person)
deleted, _ = await sync_to_async(
lambda: PatternMitigationCorrection.objects.filter(
user=sender_user,
plan=plan,
title__iexact=title,
).delete()
)()
sym("Correction deleted." if deleted else "Correction not found.")
return True
if command.startswith(".mitigation fundamentals-set "):
payload = command.replace(".mitigation fundamentals-set ", "", 1)
parts = parse_parts(payload)
if len(parts) < 2:
sym("Usage: .mitigation fundamentals-set <person>|<item1;item2;...>")
return True
person_name, values = parts[0].title(), "|".join(parts[1:])
person = await sync_to_async(
lambda: Person.objects.filter(
user=sender_user, name__iexact=person_name
).first()
)()
if not person:
sym("Unknown person.")
return True
plan = await sync_to_async(self._get_or_create_plan)(sender_user, person)
items = [item.strip() for item in values.split(";") if item.strip()]
plan.fundamental_items = items
await sync_to_async(plan.save)(
update_fields=["fundamental_items", "updated_at"]
)
sym(f"Fundamentals updated ({len(items)}).")
return True
if command.startswith(".mitigation plan-set "):
payload = command.replace(".mitigation plan-set ", "", 1)
parts = parse_parts(payload)
if len(parts) < 3:
sym(
"Usage: .mitigation plan-set <person>|<draft|active|archived>|<auto|guided>"
)
return True
person_name, status_value, mode_value = (
parts[0].title(),
parts[1].lower(),
parts[2].lower(),
)
person = await sync_to_async(
lambda: Person.objects.filter(
user=sender_user, name__iexact=person_name
).first()
)()
if not person:
sym("Unknown person.")
return True
plan = await sync_to_async(self._get_or_create_plan)(sender_user, person)
valid_status = {key for key, _ in PatternMitigationPlan.STATUS_CHOICES}
valid_modes = {
key for key, _ in PatternMitigationPlan.CREATION_MODE_CHOICES
}
if status_value in valid_status:
plan.status = status_value
if mode_value in valid_modes:
plan.creation_mode = mode_value
await sync_to_async(plan.save)(
update_fields=["status", "creation_mode", "updated_at"]
)
sym(f"Plan updated: status={plan.status}, mode={plan.creation_mode}")
return True
if command.startswith(".mitigation auto "):
payload = command.replace(".mitigation auto ", "", 1)
parts = parse_parts(payload)
if len(parts) < 2:
sym("Usage: .mitigation auto <person>|on|off")
return True
person_name, state = parts[0].title(), parts[1].lower()
person = await sync_to_async(
lambda: Person.objects.filter(
user=sender_user, name__iexact=person_name
).first()
)()
if not person:
sym("Unknown person.")
return True
conversation = await sync_to_async(self._get_workspace_conversation)(
sender_user, person
)
auto_obj, _ = await sync_to_async(
PatternMitigationAutoSettings.objects.get_or_create
)(
user=sender_user,
conversation=conversation,
)
auto_obj.enabled = state in {"on", "true", "1", "yes"}
await sync_to_async(auto_obj.save)(update_fields=["enabled", "updated_at"])
sym(
f"Automation {'enabled' if auto_obj.enabled else 'disabled'} for {person.name}."
)
return True
if command.startswith(".mitigation auto-status "):
person_name = (
command.replace(".mitigation auto-status ", "", 1).strip().title()
)
person = await sync_to_async(
lambda: Person.objects.filter(
user=sender_user, name__iexact=person_name
).first()
)()
if not person:
sym("Unknown person.")
return True
conversation = await sync_to_async(self._get_workspace_conversation)(
sender_user, person
)
auto_obj, _ = await sync_to_async(
PatternMitigationAutoSettings.objects.get_or_create
)(
user=sender_user,
conversation=conversation,
)
sym(
f"{person.name}: auto={'on' if auto_obj.enabled else 'off'}, "
f"pattern={'on' if auto_obj.auto_pattern_recognition else 'off'}, "
f"corrections={'on' if auto_obj.auto_create_corrections else 'off'}"
)
return True
return False
def update_roster(self, jid, name=None):
"""
Adds or updates a user in the roster.
"""
iq = self.Iq()
iq["type"] = "set"
iq["roster"]["items"] = {jid: {"name": name or jid}}
iq.send()
self.log.debug("Updated roster: added %s (%s)", jid, name)
def on_chatstate_active(self, msg):
"""
Handle when a user is actively engaged in the chat.
"""
self.log.debug("Chat state active from %s", msg["from"])
self.get_identifier(msg)
def on_chatstate_composing(self, msg):
"""
Handle when a user is typing a message.
"""
self.log.debug("Chat state composing from %s", msg["from"])
identifier = self.get_identifier(msg)
if identifier:
asyncio.create_task(
self.ur.started_typing(
"xmpp",
identifier=identifier,
)
)
def on_chatstate_paused(self, msg):
"""
Handle when a user has paused typing.
"""
self.log.debug("Chat state paused from %s", msg["from"])
identifier = self.get_identifier(msg)
if identifier:
asyncio.create_task(
self.ur.stopped_typing(
"xmpp",
identifier=identifier,
)
)
def on_chatstate_inactive(self, msg):
"""
Handle when a user is inactive in the chat.
"""
self.log.debug("Chat state inactive from %s", msg["from"])
self.get_identifier(msg)
def on_chatstate_gone(self, msg):
"""
Handle when a user has left the chat.
"""
self.log.debug("Chat state gone from %s", msg["from"])
self.get_identifier(msg)
def on_presence_available(self, pres):
"""
Handle when a user becomes available.
"""
self.log.debug("Presence available from %s", pres["from"])
def on_presence_dnd(self, pres):
"""
Handle when a user sets 'Do Not Disturb' status.
"""
self.log.debug("Presence dnd from %s", pres["from"])
def on_presence_xa(self, pres):
"""
Handle when a user sets 'Extended Away' status.
"""
self.log.debug("Presence extended-away from %s", pres["from"])
def on_presence_chat(self, pres):
"""
Handle when a user is actively available for chat.
"""
self.log.debug("Presence chat-available from %s", pres["from"])
def on_presence_away(self, pres):
"""
Handle when a user sets 'Away' status.
"""
self.log.debug("Presence away from %s", pres["from"])
def on_presence_unavailable(self, pres):
"""
Handle when a user goes offline or unavailable.
"""
self.log.debug("Presence unavailable from %s", pres["from"])
def on_presence_subscribe(self, pres):
"""
Handle incoming presence subscription requests.
Accept subscriptions to:
1. The gateway component JID itself - for OMEMO device discovery
2. User contacts at the gateway component JID - for user-to-user messaging
"""
sender_jid = str(pres["from"]).split("/")[0] # Bare JID (user@domain)
recipient_jid = str(pres["to"]).split("/")[0]
component_jid = str(self.boundjid.bare)
self.log.debug(
f"Received subscription request from {sender_jid} to {recipient_jid}"
)
# Check if subscription is to the gateway component itself
if recipient_jid == component_jid:
# Auto-accept subscriptions to the gateway component (for OMEMO)
self.log.info(
"Auto-accepting subscription to gateway component from %s", sender_jid
)
self.send_presence(ptype="subscribed", pto=sender_jid, pfrom=component_jid)
# Send presence availability to enable device discovery
self.send_presence(ptype="available", pto=sender_jid, pfrom=component_jid)
self.log.info(
"Gateway component is available to %s (OMEMO device discovery enabled)",
sender_jid,
)
return
# Otherwise, handle user-to-user subscription (existing logic)
try:
# Extract sender and recipient usernames
user_username, _ = sender_jid.split("@", 1)
recipient_username, _ = recipient_jid.split("@", 1)
# Parse recipient_name and recipient_service (e.g., "mark|signal")
if "|" in recipient_username:
person_name, service = recipient_username.split("|", 1)
person_name = person_name.strip()
service = service.strip().lower()
else:
person_name = recipient_username.strip()
service = None
# Lookup user in Django
self.log.debug("Resolving subscription user=%s", user_username)
user = User.objects.get(username=user_username)
self.log.debug("Resolving subscription person=%s", person_name)
person = _resolve_person_from_xmpp_localpart(
user=user, localpart_value=person_name
)
if person is None:
raise Person.DoesNotExist(f"No person found for '{person_name}'")
# Ensure a PersonIdentifier exists for this user, person, and service
self.log.debug("Resolving subscription identifier service=%s", service)
if service:
identifier = _select_person_identifier(
user=user,
person=person,
service=service,
)
if identifier is None:
raise PersonIdentifier.DoesNotExist(
f"No identifier found for person '{person_name}'"
)
else:
fallback_identifier = _select_person_identifier(
user=user,
person=person,
)
if fallback_identifier is None:
raise PersonIdentifier.DoesNotExist(
f"No identifier found for person '{person_name}'"
)
service = str(fallback_identifier.service or "").strip().lower()
contact_jid = self._contact_component_jid(person.name, service)
# Accept the subscription
self.send_presence(ptype="subscribed", pto=sender_jid, pfrom=contact_jid)
self.log.debug(
f"Accepted subscription from {sender_jid}, sent from {contact_jid}"
)
# Ask the XMPP client to grant reciprocal presence so roster-driven
# clients do not leave bridged contacts stuck unsubscribed/offline.
self.send_presence(ptype="subscribe", pto=sender_jid, pfrom=contact_jid)
self.log.debug(
"Requested reciprocal subscription from %s to %s",
contact_jid,
sender_jid,
)
# Add sender to roster
# self.update_roster(sender_jid, name=sender_jid.split("@")[0])
# Send presence update to sender **from the correct JID**
self.send_presence(ptype="available", pto=sender_jid, pfrom=contact_jid)
self.log.debug(
"Sent presence update from %s to %s", contact_jid, sender_jid
)
except (User.DoesNotExist, Person.DoesNotExist, PersonIdentifier.DoesNotExist):
# If any lookup fails, reject the subscription
self.log.warning(
f"Subscription request from {sender_jid} rejected (recipient does not have this contact)."
)
self.send_presence(ptype="unsubscribed", pto=sender_jid)
except ValueError:
return
def on_presence_subscribed(self, pres):
"""
Handle successful subscription confirmations.
"""
self.log.debug("Subscription to %s accepted", pres["from"])
def on_presence_unsubscribe(self, pres):
"""
Handle when a user unsubscribes from presence updates.
"""
self.log.debug("Presence unsubscribe from %s", pres["from"])
def on_presence_unsubscribed(self, pres):
"""
Handle when a user's unsubscription request is confirmed.
"""
self.log.debug("Presence unsubscribed confirmation from %s", pres["from"])
def on_roster_subscription_request(self, pres):
"""
Handle roster subscription requests.
"""
self.log.debug("Roster subscription request from %s", pres["from"])
async def session_start(self, *args):
self.log.info("XMPP session started")
self._session_live = True
self._connect_inflight = False
self._reconnect_delay_seconds = 1.0
if self._reconnect_task and not self._reconnect_task.done():
self._reconnect_task.cancel()
self._reconnect_task = None
# This client connects as an external component, not a user client;
# XEP-0280 (carbons) is client-scoped and not valid here.
self.log.debug("Skipping carbons enable for component session")
await self._bootstrap_omemo_for_authentic_channel()
def on_privileges_advertised(self, *_args):
plugin = self.plugin.get("xep_0356", None)
if plugin is None:
return
self.log.info(
"XMPP privileged capabilities advertised: %s",
plugin.granted_privileges,
)
async def _reconnect_loop(self):
try:
while True:
delay = float(self._reconnect_delay_seconds)
await asyncio.sleep(delay)
if self._session_live or self._connect_inflight:
return
try:
self.log.info("XMPP reconnect attempt delay_s=%.1f", delay)
self._connect_inflight = True
connected = self.connect()
if connected is False:
raise RuntimeError("connect returned false")
return
except Exception as exc:
self.log.warning("XMPP reconnect attempt failed: %s", exc)
self._connect_inflight = False
self._reconnect_delay_seconds = min(
self._reconnect_delay_max_seconds,
max(1.0, float(self._reconnect_delay_seconds) * 2.0),
)
except asyncio.CancelledError:
return
finally:
if not self._session_live:
self._connect_inflight = False
self._reconnect_task = None
def _schedule_reconnect(self):
if self._reconnect_task and not self._reconnect_task.done():
return
self._reconnect_task = self.loop.create_task(self._reconnect_loop())
def on_disconnected(self, *args):
"""
Handles XMPP disconnection and triggers a reconnect loop.
"""
self._session_live = False
self._connect_inflight = False
self.log.warning(
"XMPP disconnected, scheduling reconnect attempt in %.1fs",
float(self._reconnect_delay_seconds),
)
self._schedule_reconnect()
async def request_upload_slot(self, recipient, filename, content_type, size):
"""
Requests an upload slot from XMPP for HTTP File Upload (XEP-0363).
Args:
recipient (str): The JID of the recipient.
filename (str): The filename for the upload.
content_type (str): The file's MIME type.
size (int): The file size in bytes.
Returns:
tuple | None: (upload_url, put_url, auth_header) or None if failed.
"""
# upload_service = await self['xep_0363'].find_upload_service()
# if not upload_service:
# self.log.error("No XEP-0363 upload service found.")
# return None
upload_service_jid = str(
getattr(settings, "XMPP_UPLOAD_SERVICE", "")
or getattr(settings, "XMPP_UPLOAD_JID", "")
).strip()
candidate_services = []
if upload_service_jid:
candidate_services.append(upload_service_jid)
if not upload_service_jid:
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:
candidate_services.append(upload_service_jid)
self.log.info(
"Discovered XMPP upload service via XEP-0363: %s",
upload_service_jid,
)
default_service = str(getattr(settings, "XMPP_USER_DOMAIN", "") or "").strip()
if default_service:
candidate_services.append(default_service)
deduped_services = []
for candidate in candidate_services:
cleaned = str(candidate or "").strip()
if cleaned and cleaned not in deduped_services:
deduped_services.append(cleaned)
if not deduped_services:
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
last_error = None
for service_jid in deduped_services:
try:
slot = await self["xep_0363"].request_slot(
jid=service_jid,
filename=filename,
content_type=content_type,
size=size,
)
if slot is None:
last_error = f"empty slot response from {service_jid}"
continue
root = ET.fromstring(str(slot))
namespace = "{urn:xmpp:http:upload:0}"
get_url = root.find(f".//{namespace}get").attrib.get("url")
put_element = root.find(f".//{namespace}put")
put_url = put_element.attrib.get("url")
header_element = put_element.find(
f"./{namespace}header[@name='Authorization']"
)
auth_header = (
header_element.text.strip() if header_element is not None else None
)
if not get_url or not put_url:
last_error = f"missing URLs in upload slot from {service_jid}"
continue
if service_jid != deduped_services[0]:
self.log.info(
"XMPP upload service fallback succeeded via %s",
service_jid,
)
return get_url, put_url, auth_header
except Exception as exc:
last_error = str(exc)
self.log.warning(
"XMPP upload slot request failed via %s: %s", service_jid, exc
)
self.log.error("Failed to obtain upload slot for %s: %s", filename, last_error)
return None
async def message(self, msg):
"""
Process incoming XMPP messages.
"""
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"])
# Split into username@domain and optional resource
sender_parts = sender_jid.split("/", 1)
sender_bare_jid = sender_parts[0] # Always present: user@domain
sender_username, sender_domain = sender_bare_jid.split("@", 1)
sender_resource = (
sender_parts[1] if len(sender_parts) > 1 else None
) # Extract resource if present
# Extract recipient JID (should match component JID format)
recipient_jid = str(msg["to"])
if "@" in recipient_jid:
recipient_username, recipient_domain = recipient_jid.split("@", 1)
else:
recipient_username = recipient_jid
recipient_domain = recipient_jid
# Attempt to decrypt OMEMO-encrypted messages before body extraction.
original_msg = msg
omemo_plugin = self._get_omemo_plugin()
sender_omemo_fingerprint = ""
omemo_decrypt_error = ""
was_omemo_encrypted = False
if omemo_plugin:
try:
if omemo_plugin.is_encrypted(msg):
was_omemo_encrypted = True
decrypted, sender_device = await omemo_plugin.decrypt_message(msg)
msg = decrypted
sender_omemo_fingerprint = _format_omemo_identity_fingerprint(
getattr(sender_device, "identity_key", b"")
)
self.log.debug("OMEMO: decrypted message from %s", sender_jid)
except Exception as exc:
omemo_decrypt_error = str(exc or "").strip()
self.log.warning(
"OMEMO: decryption failed from %s: %s", sender_jid, exc
)
# 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(
"Received XMPP stanza: %s", ET.tostring(msg.xml, encoding="unicode")
)
# 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
try:
safe_url = validate_attachment_url(url_value)
filename, content_type = validate_attachment_metadata(
filename=att.attrib.get("filename") or _filename_from_url(safe_url),
content_type=att.attrib.get("content_type")
or "application/octet-stream",
)
except Exception as exc:
self.log.warning(
"xmpp dropped unsafe attachment url=%s: %s", url_value, exc
)
continue
attachments.append(
{
"url": safe_url,
"filename": filename,
"content_type": content_type,
}
)
# 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
try:
safe_url = validate_attachment_url(url_value)
filename, guessed_content_type = validate_attachment_metadata(
filename=_filename_from_url(safe_url),
content_type=_content_type_from_filename_or_url(safe_url),
)
except Exception as exc:
self.log.warning("xmpp dropped unsafe oob url=%s: %s", url_value, exc)
continue
attachments.append(
{
"url": safe_url,
"filename": filename,
"content_type": guessed_content_type,
}
)
# 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
try:
safe_url = validate_attachment_url(url_value)
filename, guessed_content_type = validate_attachment_metadata(
filename=_filename_from_url(safe_url),
content_type=_content_type_from_filename_or_url(safe_url),
)
except Exception as exc:
self.log.warning(
"xmpp dropped extracted unsafe url=%s: %s", url_value, exc
)
continue
attachments.append(
{
"url": safe_url,
"filename": filename,
"content_type": guessed_content_type,
}
)
# Some XMPP clients send HTTP upload links only in the body text.
if not attachments:
body_url_attachments = _attachment_rows_from_body_urls(body)
if body_url_attachments:
attachments.extend(body_url_attachments)
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)
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 = ""
body = ""
self.log.debug("Extracted %s attachments from XMPP message", len(attachments))
# 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 or '[No Body]'}"
)
self.log.debug(log_message)
# Ensure recipient domain matches our configured component
expected_domain = settings.XMPP_JID
if recipient_domain != expected_domain:
self.log.warning(
f"Invalid recipient domain: {recipient_domain}, expected {expected_domain}"
)
return
# Lookup sender in Django's User model
try:
sender_user = User.objects.get(username=sender_username)
except User.DoesNotExist:
self.log.warning(f"Unknown sender: {sender_username}")
return
# Record the sender's OMEMO state (uses the original, pre-decryption stanza).
try:
await self._record_sender_omemo_state(
sender_user,
sender_jid=sender_jid,
recipient_jid=recipient_jid,
message_stanza=original_msg,
sender_fingerprint=sender_omemo_fingerprint,
)
except Exception as exc:
self.log.warning("OMEMO: failed to record sender state: %s", exc)
omemo_observation = _extract_sender_omemo_client_key(original_msg)
# Enforce mandatory encryption policy.
sec_settings = None
try:
from core.models import UserXmppSecuritySettings
sec_settings = await sync_to_async(
lambda: UserXmppSecuritySettings.objects.filter(
user=sender_user
).first()
)()
if sec_settings and sec_settings.require_omemo:
omemo_status = str(omemo_observation.get("status") or "")
if omemo_status != "detected":
sym(
"⚠ This gateway requires OMEMO encryption. "
"Your message was not delivered. "
"Please enable OMEMO in your XMPP client."
)
return
except Exception as exc:
self.log.warning("OMEMO policy check failed: %s", exc)
component_encrypt_with_omemo = True
if sec_settings is not None:
component_encrypt_with_omemo = bool(
getattr(
sec_settings,
"encrypt_component_messages_with_omemo",
True,
)
)
if (
was_omemo_encrypted
and omemo_decrypt_error
and not body.strip()
and not attachments
and not parsed_reaction
):
await self.send_xmpp_message(
sender_jid,
settings.XMPP_JID,
"This gateway could not decrypt your OMEMO-encrypted message or attachment, so it was not delivered. Disable OMEMO for this contact or send it unencrypted.",
use_omemo_encryption=component_encrypt_with_omemo,
)
return
if recipient_jid == settings.XMPP_JID:
self.log.debug("Handling command message sent to gateway JID")
if body.startswith("."):
gateway_replies = await self.execute_gateway_command(
sender_user=sender_user,
body=body,
service="xmpp",
channel_identifier=str(sender_jid or ""),
sender_identifier=str(sender_jid or ""),
sender_jid=sender_jid,
recipient_jid=recipient_jid,
local_message=None,
message_meta={
"xmpp": {
"sender_jid": str(sender_jid or ""),
"recipient_jid": str(recipient_jid or ""),
"omemo_status": str(omemo_observation.get("status") or ""),
"omemo_client_key": str(
omemo_observation.get("client_key") or ""
),
}
},
)
for line in gateway_replies:
await self.send_xmpp_message(
sender_jid,
settings.XMPP_JID,
f"[>] {line}",
use_omemo_encryption=component_encrypt_with_omemo,
)
else:
self.log.debug("Handling routed message to contact")
if "|" in recipient_username:
recipient_name, recipient_service = recipient_username.split("|", 1)
recipient_name = recipient_name.strip()
recipient_service = recipient_service.strip().lower()
else:
recipient_name = recipient_username.strip()
recipient_service = None
person = _resolve_person_from_xmpp_localpart(
user=sender_user, localpart_value=recipient_name
)
if person is None:
sym("This person does not exist.")
return
if recipient_service:
identifier = PersonIdentifier.objects.filter(
user=sender_user, person=person, service=recipient_service
).first()
if identifier is None:
sym("This service identifier does not exist.")
return
else:
# Get a random identifier
identifier = PersonIdentifier.objects.filter(
user=sender_user, person=person
).first()
if identifier is None:
sym("This service identifier does not exist.")
return
recipient_service = identifier.service
# 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)
session, _ = await sync_to_async(ChatSession.objects.get_or_create)(
identifier=identifier,
user=identifier.user,
)
self.log.debug("Storing outbound XMPP message in history")
reply_ref = reply_sync.extract_reply_ref(
"xmpp",
{
"reply_source_message_id": parsed_reply_target,
"reply_source_chat_id": str(sender_jid or ""),
},
)
reply_target = await reply_sync.resolve_reply_target(
identifier.user,
session,
reply_ref,
)
local_message = await history.store_message(
session=session,
sender="XMPP",
text=body,
ts=int(now().timestamp() * 1000),
outgoing=True,
source_service="xmpp",
source_message_id=xmpp_message_id,
source_chat_id=str(sender_jid or ""),
reply_to=reply_target,
reply_source_service=str(reply_ref.get("reply_source_service") or ""),
reply_source_message_id=str(
reply_ref.get("reply_source_message_id") or ""
),
message_meta={
"xmpp": {
"sender_jid": str(sender_jid or ""),
"recipient_jid": str(recipient_jid or ""),
"omemo_status": str(omemo_observation.get("status") or ""),
"omemo_client_key": str(
omemo_observation.get("client_key") or ""
),
"attachments": [
{
"url": str(item.get("url") or ""),
"filename": str(item.get("filename") or ""),
"content_type": str(
item.get("content_type")
or "application/octet-stream"
),
}
for item in attachments
],
}
},
)
self.log.debug("Stored outbound XMPP message in history")
await self.ur.message_received(
"xmpp",
identifier=identifier,
text=body,
ts=int(now().timestamp() * 1000),
payload={
"sender_jid": sender_jid,
"recipient_jid": recipient_jid,
},
local_message=local_message,
)
manipulations = Manipulation.objects.filter(
group__people=identifier.person,
user=identifier.user,
mode="mutate",
enabled=True,
)
self.log.debug("Found %s active manipulations", manipulations.count())
if not manipulations:
await self.ur.stopped_typing(
"xmpp",
identifier=identifier,
payload={"reason": "message_sent"},
)
await identifier.send(
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.info(
"Relayed XMPP message to %s attachment_count=%s text_len=%s",
recipient_service,
len(attachments),
len(str(relay_body or "")),
)
self.log.debug("Message sent unaltered")
return
manip = manipulations.first()
chat_history = await history.get_chat_history(session)
await utils.update_last_interaction(session)
prompt = replies.generate_mutate_reply_prompt(
relay_body,
identifier.person,
manip,
chat_history,
)
self.log.debug("Running XMPP context prompt")
result = await ai.run_prompt(
prompt,
manip.ai,
operation="xmpp_mutate",
)
self.log.debug("Generated mutated response for XMPP message")
await history.store_own_message(
session=session,
text=result,
ts=int(now().timestamp() * 1000),
)
await self.ur.stopped_typing(
"xmpp",
identifier=identifier,
payload={"reason": "message_sent"},
)
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")
async def request_upload_slots(self, recipient_jid, attachments):
"""Requests upload slots for multiple attachments concurrently."""
upload_tasks = [
self.request_upload_slot(
recipient_jid, att["filename"], att["content_type"], att["size"]
)
for att in attachments
]
upload_slots = await asyncio.gather(*upload_tasks)
return [
(att, slot)
for att, slot in zip(attachments, upload_slots)
if slot is not None
]
async def upload_and_send(self, att, upload_slot, recipient_jid, sender_jid):
"""Uploads a file and immediately sends the corresponding XMPP message."""
upload_url, put_url, auth_header = upload_slot
try:
filename, content_type = validate_attachment_metadata(
filename=att.get("filename"),
content_type=att.get("content_type"),
size=att.get("size"),
)
except Exception as exc:
self.log.warning("xmpp blocked outbound attachment: %s", exc)
return None
headers = {
"Content-Type": content_type,
"Content-Length": str(
int(att.get("size") or len(att.get("content") or b""))
),
}
if auth_header:
headers["Authorization"] = auth_header
async with aiohttp.ClientSession() as session:
try:
async with session.put(
put_url, data=att["content"], headers=headers
) as response:
if response.status not in (200, 201):
self.log.error(
f"Upload failed: {response.status} {await response.text()}"
)
return None
self.log.debug("Successfully uploaded %s to %s", filename, upload_url)
# Send XMPP message immediately after successful upload
xmpp_msg_id = await self.send_xmpp_message(
recipient_jid, sender_jid, upload_url, attachment_url=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}")
return None
async def send_xmpp_message(
self,
recipient_jid,
sender_jid,
body_text,
attachment_url=None,
*,
use_omemo_encryption=True,
):
"""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:
# Include <x><url> (XEP-0066) to ensure client compatibility
oob_element = ET.Element("{jabber:x:oob}x")
url_element = ET.SubElement(oob_element, "{jabber:x:oob}url")
url_element.text = attachment_url
msg.xml.append(oob_element)
self.log.debug("Sending XMPP message: %s", msg.xml)
# Attempt OMEMO encryption for text-only messages (not attachments)
# when outbound policy allows it.
if not attachment_url and use_omemo_encryption:
omemo_plugin = self._get_omemo_plugin()
if omemo_plugin:
try:
from slixmpp.jid import JID as _JID
encrypted_msgs, enc_errors = await omemo_plugin.encrypt_message(
msg, _JID(recipient_jid)
)
if enc_errors:
self.log.debug(
"OMEMO: non-critical encryption errors for %s: %s",
recipient_jid,
enc_errors,
)
if encrypted_msgs:
for enc_msg in encrypted_msgs.values():
enc_msg.send()
self.log.debug(
"OMEMO: sent encrypted message to %s", recipient_jid
)
return msg_id
except Exception as exc:
self.log.debug(
"OMEMO: encryption not available for %s, sending plaintext: %s",
recipient_jid,
exc,
)
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 = self._contact_component_jid(
person_identifier.person.name, person_identifier.service
)
recipient_jid = self._user_jid(user.username)
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."""
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.debug(
"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 = self._contact_component_jid(
person_identifier.person.name, person_identifier.service
)
recipient_jid = self._user_jid(user.username)
await self.send_chat_state(recipient_jid, sender_jid, started)
async def send_from_external(
self,
user,
person_identifier,
text,
is_outgoing_message,
attachments=[],
source_ref=None,
):
"""Handles sending XMPP messages with text and attachments."""
sender_jid = self._contact_component_jid(
person_identifier.person.name, person_identifier.service
)
recipient_jid = self._user_jid(person_identifier.user.username)
relay_encrypt_with_omemo = True
try:
from core.models import UserXmppSecuritySettings
sec_settings = await sync_to_async(
lambda: UserXmppSecuritySettings.objects.filter(user=user).first()
)()
if sec_settings is not None:
relay_encrypt_with_omemo = bool(
getattr(sec_settings, "encrypt_contact_messages_with_omemo", True)
)
except Exception as exc:
self.log.warning("XMPP relay OMEMO settings lookup failed: %s", exc)
if is_outgoing_message:
xmpp_id = await self.send_xmpp_message(
recipient_jid,
sender_jid,
f"YOU: {text}",
use_omemo_encryption=relay_encrypt_with_omemo,
)
try:
await self.send_sent_carbon_copy(
user_jid=recipient_jid,
contact_jid=sender_jid,
body_text=text,
)
except Exception as exc:
self.log.warning(
"Sent carbon copy failed after fallback relay user=%s contact=%s: %s",
recipient_jid,
sender_jid,
exc,
)
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:
xmpp_id = await self.send_xmpp_message(
recipient_jid,
sender_jid,
text,
use_omemo_encryption=relay_encrypt_with_omemo,
)
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
# Step 2: Request upload slots concurrently
valid_uploads = await self.request_upload_slots(recipient_jid, attachments)
self.log.debug("Got upload slots")
if not valid_uploads:
self.log.debug("No valid upload slots obtained; attachment relay skipped")
return []
# Step 3: Upload each file and send its message immediately after upload
upload_tasks = [
self.upload_and_send(att, slot, recipient_jid, sender_jid)
for att, slot in valid_uploads
]
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:
if is_outgoing_message:
try:
await self.send_sent_carbon_copy(
user_jid=recipient_jid,
contact_jid=sender_jid,
body_text=str(row.get("url") or ""),
attachment_url=str(row.get("url") or ""),
)
except Exception as exc:
self.log.warning(
"Attachment sent carbon copy failed user=%s contact=%s: %s",
recipient_jid,
sender_jid,
exc,
)
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):
def __init__(self, ur, *args, **kwargs):
super().__init__(ur, *args, **kwargs)
self._enabled = True
self.client = None
jid = str(getattr(settings, "XMPP_JID", "") or "").strip()
secret = str(getattr(settings, "XMPP_SECRET", "") or "").strip()
server = str(getattr(settings, "XMPP_ADDRESS", "") or "").strip()
port = int(getattr(settings, "XMPP_PORT", 8888) or 8888)
missing = []
if not jid:
missing.append("XMPP_JID")
if not secret:
missing.append("XMPP_SECRET")
if not server:
missing.append("XMPP_ADDRESS")
if missing:
self._enabled = False
self.log.warning(
"XMPP client disabled due to missing configuration: %s",
", ".join(missing),
)
if self._enabled:
self.client = XMPPComponent(
ur,
jid=jid,
secret=secret,
server=server,
port=port,
)
self.client.register_plugin("xep_0030") # Service Discovery
self.client.register_plugin("xep_0004") # Data Forms
self.client.register_plugin("xep_0060") # PubSub
self.client.register_plugin("xep_0199") # XMPP Ping
self.client.register_plugin("xep_0297") # Forwarded
self.client.register_plugin("xep_0356") # Privileged Entity
self.client.register_plugin("xep_0085") # Chat State Notifications
self.client.register_plugin("xep_0363") # HTTP File Upload
self._omemo_plugin_registered = False
if _OMEMO_AVAILABLE:
try:
data_dir = str(
getattr(settings, "XMPP_OMEMO_DATA_DIR", "") or ""
).strip()
if not data_dir:
data_dir = str(Path(settings.BASE_DIR) / "xmpp_omemo_data")
# Register our concrete plugin class under the "xep_0384" name so
# that slixmpp's dependency resolver finds it.
_slixmpp_register_plugin(_GiaOmemoPlugin)
self.client.register_plugin(
"xep_0384", pconfig={"data_dir": data_dir}
)
self._omemo_plugin_registered = True
self.log.info(
"OMEMO: xep_0384 plugin registered, data_dir=%s", data_dir
)
except Exception as exc:
self.log.warning(
"OMEMO: failed to register xep_0384 plugin: %s", exc
)
else:
self.log.warning("OMEMO: slixmpp_omemo not available, OMEMO disabled")
def start(self):
if not self._enabled or self.client is None:
return
self.log.info("XMPP client starting...")
# ensure slixmpp uses the same asyncio loop as the router
self.client.loop = self.loop
self.client.connect()
async def start_typing_for_person(self, user, person_identifier):
if self.client is None:
return
await self.client.send_typing_for_person(user, person_identifier, True)
async def stop_typing_for_person(self, user, person_identifier):
if self.client is None:
return
await self.client.send_typing_for_person(user, person_identifier, False)