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 | " ".mitigation rule-add ||<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)