import asyncio import base64 import json import mimetypes import os import re import time import uuid from pathlib import Path from urllib.parse import parse_qs, urlparse, 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.stanza import Message from slixmpp.xmlstream import register_stanza_plugin from slixmpp.xmlstream.stanzabase import ET from core.clients import ClientBase, transport from core.gateway.commands import ( GatewayCommandContext, GatewayCommandRoute, dispatch_gateway_command, ) from core.messaging import ai, history, replies, reply_sync, utils from core.models import ( ChatSession, CodexPermissionRequest, CodexRun, DerivedTask, ExternalSyncEvent, 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<>'\"\\]+") 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,}$") def _clean_url(value): return str(value or "").strip().rstrip(".,);:!?\"'") def _filename_from_url(url_value): path = urlsplit(str(url_value or "")).path name = path.rsplit("/", 1)[-1] return name or "attachment" def _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 _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"} # --------------------------------------------------------------------------- # OMEMO storage + plugin implementation # --------------------------------------------------------------------------- try: from omemo.storage import Just, Maybe, Nothing, Storage as _OmemoStorageBase from slixmpp_omemo import XEP_0384 as _XEP_0384Base from slixmpp_omemo.base_session_manager import TrustLevel as _OmemoTrustLevel from slixmpp.plugins.base import register_plugin as _slixmpp_register_plugin _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") 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) # 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 ) # 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) 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()}" 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): 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) # 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("|") person_name = person_name.title() # Capitalize for consistency else: person_name = recipient_username.title() service = None try: # Lookup user in Django self.log.debug("Resolving XMPP sender user=%s", sender_username) user = User.objects.get(username=sender_username) # Find Person object with name=person_name.lower() self.log.debug("Resolving XMPP recipient person=%s", person_name.title()) person = Person.objects.get(user=user, name=person_name.title()) # Ensure a PersonIdentifier exists for this user, person, and service self.log.debug("Resolving XMPP identifier service=%s", service) identifier = PersonIdentifier.objects.get( user=user, person=person, service=service ) 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 session_manager = await _asyncio.wait_for( omemo_plugin.get_session_manager(), timeout=15.0 ) own_devices = await session_manager.get_own_device_information() if own_devices: key_bytes = own_devices[0].identity_key fingerprint = ":".join(f"{b:02X}" for b in key_bytes) except Exception as exc: self.log.warning("OMEMO: could not read own device fingerprint: %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): parsed = _extract_sender_omemo_client_key(message_stanza) status = str(parsed.get("status") or "no_omemo") client_key = str(parsed.get("client_key") or "") await sync_to_async(UserXmppOmemoState.objects.update_or_create)( user=user, defaults={ "status": status, "latest_client_key": client_key, "last_sender_jid": str(sender_jid or ""), "last_target_jid": str(recipient_jid or ""), }, ) _approval_event_prefix = "codex_approval" _APPROVAL_PROVIDER_COMMANDS = { ".claude": "claude", ".codex": "codex_cli", } def _resolve_request_provider(self, request): event = getattr(request, "external_sync_event", None) if event is None: return "" return str(getattr(event, "provider", "") or "").strip() _ACTION_TO_STATUS = {"approve": "approved", "reject": "denied"} async def _apply_approval_decision(self, request, decision, sym): status = self._ACTION_TO_STATUS.get(decision, decision) request.status = status await sync_to_async(request.save)(update_fields=["status"]) run = None if request.codex_run_id: run = await sync_to_async(CodexRun.objects.get)(pk=request.codex_run_id) run.status = "approved_waiting_resume" if status == "approved" else status await sync_to_async(run.save)(update_fields=["status"]) if request.external_sync_event_id: evt = await sync_to_async(ExternalSyncEvent.objects.get)(pk=request.external_sync_event_id) evt.status = "ok" await sync_to_async(evt.save)(update_fields=["status"]) user = await sync_to_async(User.objects.get)(pk=request.user_id) task = None if run is not None and run.task_id: task = await sync_to_async(DerivedTask.objects.get)(pk=run.task_id) ikey = f"{self._approval_event_prefix}:{request.approval_key}:{status}" await sync_to_async(ExternalSyncEvent.objects.get_or_create)( idempotency_key=ikey, defaults={ "user": user, "task": task, "provider": "codex_cli", "status": "pending", "payload": {}, "error": "", }, ) async def _approval_list_pending(self, user, scope, sym): requests = await sync_to_async(list)( CodexPermissionRequest.objects.filter( user=user, status="pending" ).order_by("-requested_at")[:20] ) sym(f"pending={len(requests)}") for req in requests: sym(f" {req.approval_key}: {req.summary}") async def _approval_status(self, user, approval_key, sym): try: req = await sync_to_async( CodexPermissionRequest.objects.get )(user=user, approval_key=approval_key) sym(f"status={req.status} key={req.approval_key}") except CodexPermissionRequest.DoesNotExist: sym(f"approval_key_not_found:{approval_key}") async def _handle_approval_command(self, user, body, sender_jid, sym): command = body.strip() for prefix, expected_provider in self._APPROVAL_PROVIDER_COMMANDS.items(): if command.startswith(prefix + " ") or command == prefix: sub = command[len(prefix):].strip() parts = sub.split() if len(parts) >= 2 and parts[0] in ("approve", "reject"): action, approval_key = parts[0], parts[1] try: req = await sync_to_async( CodexPermissionRequest.objects.select_related( "external_sync_event" ).get )(user=user, approval_key=approval_key) except CodexPermissionRequest.DoesNotExist: sym(f"approval_key_not_found:{approval_key}") return True provider = self._resolve_request_provider(req) if not provider.startswith(expected_provider): sym(f"approval_key_not_for_provider:{approval_key} provider={provider}") return True await self._apply_approval_decision(req, action, sym) sym(f"{action}d: {approval_key}") return True sym(f"usage: {prefix} approve|reject ") return True if not command.startswith(".approval"): return False rest = command[len(".approval"):].strip() if rest.split() and rest.split()[0] in ("approve", "reject"): parts = rest.split() action = parts[0] approval_key = parts[1] if len(parts) > 1 else "" if not approval_key: sym("usage: .approval approve|reject ") return True try: req = await sync_to_async( CodexPermissionRequest.objects.select_related( "external_sync_event" ).get )(user=user, approval_key=approval_key) except CodexPermissionRequest.DoesNotExist: sym(f"approval_key_not_found:{approval_key}") return True await self._apply_approval_decision(req, action, sym) sym(f"{action}d: {approval_key}") return True if rest.startswith("list-pending"): scope = rest[len("list-pending"):].strip() or "mine" await self._approval_list_pending(user, scope, sym) return True if rest.startswith("status "): approval_key = rest[len("status "):].strip() await self._approval_status(user, approval_key, sym) return True sym( "approval: .approval approve|reject | " ".approval list-pending [all] | " ".approval status " ) return True async def _handle_tasks_command(self, user, body, sym): command = body.strip() if not command.startswith(".tasks"): return False rest = command[len(".tasks"):].strip() if rest.startswith("list"): parts = rest.split() status_filter = parts[1] if len(parts) > 1 else "open" limit = int(parts[2]) if len(parts) > 2 and parts[2].isdigit() else 10 tasks = await sync_to_async(list)( DerivedTask.objects.filter( user=user, status_snapshot=status_filter ).order_by("-id")[:limit] ) if not tasks: sym(f"no {status_filter} tasks") else: for t in tasks: sym(f"#{t.reference_code} [{t.status_snapshot}] {t.title}") return True if rest.startswith("show "): ref = rest[len("show "):].strip().lstrip("#") try: task = await sync_to_async(DerivedTask.objects.get)( user=user, reference_code=ref ) sym(f"#{task.reference_code} {task.title}") sym(f"status: {task.status_snapshot}") except DerivedTask.DoesNotExist: sym(f"task_not_found:#{ref}") return True if rest.startswith("complete "): ref = rest[len("complete "):].strip().lstrip("#") try: task = await sync_to_async(DerivedTask.objects.get)( user=user, reference_code=ref ) task.status_snapshot = "completed" await sync_to_async(task.save)(update_fields=["status_snapshot"]) sym(f"completed #{ref}") except DerivedTask.DoesNotExist: sym(f"task_not_found:#{ref}") return True if rest.startswith("undo "): ref = rest[len("undo "):].strip().lstrip("#") try: task = await sync_to_async(DerivedTask.objects.get)( user=user, reference_code=ref ) await sync_to_async(task.delete)() sym(f"removed #{ref}") except DerivedTask.DoesNotExist: sym(f"task_not_found:#{ref}") return True sym( "tasks: .tasks list [status] [limit] | " ".tasks show # | " ".tasks complete # | " ".tasks undo #" ) return True def _extract_totp_secret_candidate(self, command_text: str) -> str: text = str(command_text or "").strip() if not text: return "" lowered = text.lower() if lowered.startswith("otpauth://"): parsed = urlparse(text) query = parse_qs(parsed.query or "") return str((query.get("secret") or [""])[0] or "").strip() if lowered.startswith(".totp"): rest = text[len(".totp"):].strip() if not rest: return "" parts = rest.split(maxsplit=1) action = str(parts[0] or "").strip().lower() if action in {"enroll", "set"} and len(parts) > 1: return str(parts[1] or "").strip() if action in {"status", "help"}: return "" return rest compact = text.replace(" ", "").strip().upper() if TOTP_BASE32_SECRET_RE.match(compact): return compact return "" async def _handle_totp_command(self, user, body, sym): command = str(body or "").strip() lowered = command.lower() if lowered.startswith(".totp status"): exists = await sync_to_async( lambda: __import__( "django_otp.plugins.otp_totp.models", fromlist=["TOTPDevice"], ) .TOTPDevice.objects.filter(user=user, confirmed=True) .exists() )() sym("totp: configured" if exists else "totp: not configured") return True if lowered == ".totp help": sym("totp: .totp enroll | .totp status") return True secret_candidate = self._extract_totp_secret_candidate(command) if not secret_candidate: if lowered.startswith(".totp"): sym("usage: .totp enroll ") return True return False normalized = str(secret_candidate).replace(" ", "").strip().upper() try: key_bytes = base64.b32decode(normalized, casefold=True) except Exception: sym("totp: invalid secret format") return True if len(key_bytes) < 10: sym("totp: secret too short") return True def _save_device(): from django_otp.plugins.otp_totp.models import TOTPDevice device = ( TOTPDevice.objects.filter(user=user) .order_by("-id") .first() ) if device is None: device = TOTPDevice(user=user, name="gateway") device.key = key_bytes.hex() device.confirmed = True device.step = 30 device.t0 = 0 device.digits = 6 device.tolerance = 1 device.drift = 0 device.save() return device.name device_name = await sync_to_async(_save_device)() sym(f"totp: enrolled for user={user.username} device={device_name}") return True async def _route_gateway_command( self, *, sender_user, body, sender_jid, recipient_jid, local_message, message_meta, sym, ): command_text = str(body or "").strip() async def _contacts_handler(_ctx, emit): persons = await sync_to_async(list)(Person.objects.filter(user=sender_user).order_by("name")) if not persons: emit("No contacts found.") return True emit("Contacts: " + ", ".join([p.name for p in persons])) return True async def _help_handler(_ctx, emit): for line in self._gateway_help_lines(): emit(line) return True async def _whoami_handler(_ctx, emit): emit(str(sender_user.__dict__)) return True async def _approval_handler(_ctx, emit): return await self._handle_approval_command(sender_user, command_text, sender_jid, emit) async def _tasks_handler(_ctx, emit): return await self._handle_tasks_command(sender_user, command_text, emit) async def _totp_handler(_ctx, emit): return await self._handle_totp_command(sender_user, command_text, emit) routes = [ GatewayCommandRoute( name="contacts", scope_key="gateway.contacts", matcher=lambda text: str(text or "").strip().lower() == ".contacts", handler=_contacts_handler, ), GatewayCommandRoute( name="help", scope_key="gateway.help", matcher=lambda text: str(text or "").strip().lower() == ".help", handler=_help_handler, ), GatewayCommandRoute( name="whoami", scope_key="gateway.whoami", matcher=lambda text: str(text or "").strip().lower() == ".whoami", handler=_whoami_handler, ), GatewayCommandRoute( name="approval", scope_key="gateway.approval", matcher=lambda text: str(text or "").strip().lower().startswith(".approval") or any( str(text or "").strip().lower().startswith(prefix + " ") or str(text or "").strip().lower() == prefix for prefix in self._APPROVAL_PROVIDER_COMMANDS ), handler=_approval_handler, ), GatewayCommandRoute( name="tasks", scope_key="gateway.tasks", matcher=lambda text: str(text or "").strip().lower().startswith(".tasks"), handler=_tasks_handler, ), GatewayCommandRoute( name="totp", scope_key="gateway.totp", matcher=lambda text: bool(self._extract_totp_secret_candidate(text)), handler=_totp_handler, ), ] handled = await dispatch_gateway_command( context=GatewayCommandContext( user=sender_user, source_message=local_message, service="xmpp", channel_identifier=str(sender_jid or ""), sender_identifier=str(sender_jid or ""), message_text=command_text, message_meta=dict(message_meta or {}), payload={ "sender_jid": str(sender_jid or ""), "recipient_jid": str(recipient_jid or ""), }, ), routes=routes, emit=sym, ) if not handled and command_text.startswith("."): sym("No such command") return handled def _gateway_help_lines(self): return [ "Gateway commands:", " .contacts — list contacts", " .whoami — show current user", " .help — show this help", " .totp enroll — enroll TOTP for this user", " .totp status — show whether TOTP is configured", "Approval commands:", " .approval list-pending [all] — list pending approval requests", " .approval approve — approve a request", " .approval reject — reject a request", " .approval status — check request status", "Task commands:", " .tasks list [status] [limit] — list tasks", " .tasks show # — show task details", " .tasks complete # — mark task complete", " .tasks undo # — remove task", ] 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 only if the recipient has a contact matching the sender. """ sender_jid = str(pres["from"]).split("/")[0] # Bare JID (user@domain) recipient_jid = str(pres["to"]).split("/")[0] self.log.debug( f"Received subscription request from {sender_jid} to {recipient_jid}" ) 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("|") person_name = person_name.title() # Capitalize for consistency else: person_name = recipient_username.title() service = None # Lookup user in Django self.log.debug("Resolving subscription user=%s", user_username) user = User.objects.get(username=user_username) # Find Person object with name=person_name.lower() self.log.debug("Resolving subscription person=%s", person_name.title()) person = Person.objects.get(user=user, name=person_name.title()) # Ensure a PersonIdentifier exists for this user, person, and service self.log.debug("Resolving subscription identifier service=%s", service) PersonIdentifier.objects.get(user=user, person=person, service=service) component_jid = f"{person_name.lower()}|{service}@{self.boundjid.bare}" # Accept the subscription self.send_presence(ptype="subscribed", pto=sender_jid, pfrom=component_jid) self.log.debug( f"Accepted subscription from {sender_jid}, sent from {component_jid}" ) # Send a presence request **from the recipient to the sender** (ASKS THEM TO ACCEPT BACK) # self.send_presence(ptype="subscribe", pto=sender_jid, pfrom=component_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=component_jid) self.log.debug( "Sent presence update from %s to %s", component_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() 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() 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: self.log.info( "Discovered XMPP upload service via XEP-0363: %s", upload_service_jid, ) else: if not self._upload_config_warned: self.log.warning( "XMPP upload service not configured/discoverable; skipping attachment upload. " "Set XMPP_UPLOAD_SERVICE (or XMPP_UPLOAD_JID)." ) self._upload_config_warned = True return None try: slot = await self["xep_0363"].request_slot( jid=upload_service_jid, filename=filename, content_type=content_type, size=size, ) if slot is None: self.log.error(f"Failed to obtain upload slot for {filename}") return None # Parse the XML response root = ET.fromstring(str(slot)) # Convert to string if necessary namespace = "{urn:xmpp:http:upload:0}" # Define the namespace get_url = root.find(f".//{namespace}get").attrib.get("url") put_element = root.find(f".//{namespace}put") put_url = put_element.attrib.get("url") # Extract the Authorization header correctly 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: self.log.error(f"Missing URLs in upload slot: {slot}") return None return get_url, put_url, auth_header except Exception as e: self.log.error(f"Exception while requesting upload slot: {e}") 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() if omemo_plugin: try: if omemo_plugin.is_encrypted(msg): decrypted, _ = await omemo_plugin.decrypt_message(msg) msg = decrypted self.log.debug("OMEMO: decrypted message from %s", sender_jid) except Exception as exc: 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, } ) 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 = "" 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 # 'jews.zm.is' in your config 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, ) 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. 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) if recipient_jid == settings.XMPP_JID: self.log.debug("Handling command message sent to gateway JID") if body.startswith(".") or self._extract_totp_secret_candidate(body): await self._route_gateway_command( sender_user=sender_user, body=body, 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 ""), } }, sym=sym, ) else: self.log.debug("Handling routed message to contact") if "|" in recipient_username: recipient_name, recipient_service = recipient_username.split("|") recipient_name = recipient_name.title() else: recipient_name = recipient_username recipient_service = None recipient_name = recipient_name.title() try: person = Person.objects.get(user=sender_user, name=recipient_name) except Person.DoesNotExist: sym("This person does not exist.") if recipient_service: try: identifier = PersonIdentifier.objects.get( user=sender_user, person=person, service=recipient_service ) except PersonIdentifier.DoesNotExist: sym("This service identifier does not exist.") else: # Get a random identifier identifier = PersonIdentifier.objects.filter( user=sender_user, person=person ).first() 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) # AM FIXING https://git.zm.is/XF/GIA/issues/5 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 ""), } }, ) 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.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} 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 ): """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). if not attachment_url: 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 = ( f"{person_identifier.person.name.lower()}|" f"{person_identifier.service}@{settings.XMPP_JID}" ) 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 = ( f"{person_identifier.person.name.lower()}|" f"{person_identifier.service}@{settings.XMPP_JID}" ) 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 = f"{person_identifier.person.name.lower()}|{person_identifier.service}@{settings.XMPP_JID}" recipient_jid = self._user_jid(person_identifier.user.username) if is_outgoing_message: xmpp_id = await self.send_xmpp_message( recipient_jid, sender_jid, f"YOU: {text}", ) transport.record_bridge_mapping( user_id=user.id, person_id=person_identifier.person_id, service=person_identifier.service, xmpp_message_id=xmpp_id, xmpp_ts=int(time.time() * 1000), upstream_message_id=str( (source_ref or {}).get("upstream_message_id") or "" ), upstream_author=str((source_ref or {}).get("upstream_author") or ""), upstream_ts=int((source_ref or {}).get("upstream_ts") or 0), text_preview=str(text or ""), local_message_id=str((source_ref or {}).get("legacy_message_id") or ""), ) await history.save_bridge_ref( user=user, identifier=person_identifier, source_service=person_identifier.service, local_message_id=str((source_ref or {}).get("legacy_message_id") or ""), local_ts=int( (source_ref or {}).get("xmpp_source_ts") or int(time.time() * 1000) ), xmpp_message_id=xmpp_id, upstream_message_id=str( (source_ref or {}).get("upstream_message_id") or "" ), upstream_author=str((source_ref or {}).get("upstream_author") or ""), upstream_ts=int((source_ref or {}).get("upstream_ts") or 0), ) # Step 1: Send text message separately elif text: xmpp_id = await self.send_xmpp_message(recipient_jid, sender_jid, text) transport.record_bridge_mapping( user_id=user.id, person_id=person_identifier.person_id, service=person_identifier.service, xmpp_message_id=xmpp_id, xmpp_ts=int(time.time() * 1000), upstream_message_id=str( (source_ref or {}).get("upstream_message_id") or "" ), upstream_author=str((source_ref or {}).get("upstream_author") or ""), upstream_ts=int((source_ref or {}).get("upstream_ts") or 0), text_preview=str(text or ""), local_message_id=str((source_ref or {}).get("legacy_message_id") or ""), ) await history.save_bridge_ref( user=user, identifier=person_identifier, source_service=person_identifier.service, local_message_id=str((source_ref or {}).get("legacy_message_id") or ""), local_ts=int( (source_ref or {}).get("xmpp_source_ts") or int(time.time() * 1000) ), xmpp_message_id=xmpp_id, upstream_message_id=str( (source_ref or {}).get("upstream_message_id") or "" ), upstream_author=str((source_ref or {}).get("upstream_author") or ""), upstream_ts=int((source_ref or {}).get("upstream_ts") or 0), ) if not attachments: return [] # No attachments to process # 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: 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_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)