Files
GIA/core/clients/xmpp.py
2026-03-07 15:34:23 +00:00

2548 lines
100 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

import asyncio
import base64
import json
import 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 <key>")
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 <key>")
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 <key> | "
".approval list-pending [all] | "
".approval status <key>"
)
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 #<ref> | "
".tasks complete #<ref> | "
".tasks undo #<ref>"
)
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 <base32-secret|otpauth-uri> | .totp status")
return True
secret_candidate = self._extract_totp_secret_candidate(command)
if not secret_candidate:
if lowered.startswith(".totp"):
sym("usage: .totp enroll <base32-secret|otpauth-uri>")
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 <secret|otpauth-uri> — enroll TOTP for this user",
" .totp status — show whether TOTP is configured",
"Approval commands:",
" .approval list-pending [all] — list pending approval requests",
" .approval approve <key> — approve a request",
" .approval reject <key> — reject a request",
" .approval status <key> — check request status",
"Task commands:",
" .tasks list [status] [limit] — list tasks",
" .tasks show #<ref> — show task details",
" .tasks complete #<ref> — mark task complete",
" .tasks undo #<ref> — 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 <person> | "
".mitigation rule-add <person>|<title>|<content> | "
".mitigation rule-del <person>|<title> | "
".mitigation game-add <person>|<title>|<instructions> | "
".mitigation game-del <person>|<title> | "
".mitigation correction-add <person>|<title>|<clarification> | "
".mitigation correction-del <person>|<title> | "
".mitigation fundamentals-set <person>|<item1;item2;...> | "
".mitigation plan-set <person>|<draft|active|archived>|<auto|guided> | "
".mitigation auto <person>|on|off | "
".mitigation auto-status <person>"
)
return True
if command == ".mitigation list":
plans = await sync_to_async(list)(
PatternMitigationPlan.objects.filter(user=sender_user)
.select_related("conversation")
.order_by("-updated_at")[:15]
)
if not plans:
sym("No mitigation plans found.")
return True
rows = []
for plan in plans:
person_name = (
plan.conversation.participants.order_by("name").first().name
if plan.conversation.participants.exists()
else "Unknown"
)
rows.append(f"{person_name}: {plan.title}")
sym("Plans: " + " | ".join(rows))
return True
if command.startswith(".mitigation show "):
person_name = command.replace(".mitigation show ", "", 1).strip().title()
person = await sync_to_async(
lambda: Person.objects.filter(
user=sender_user, name__iexact=person_name
).first()
)()
if not person:
sym("Unknown person.")
return True
plan = await sync_to_async(self._get_or_create_plan)(sender_user, person)
rule_count = await sync_to_async(plan.rules.count)()
game_count = await sync_to_async(plan.games.count)()
sym(f"{person.name}: {plan.title} | rules={rule_count} games={game_count}")
return True
if command.startswith(".mitigation rule-add "):
payload = command.replace(".mitigation rule-add ", "", 1)
parts = parse_parts(payload)
if len(parts) < 3:
sym("Usage: .mitigation rule-add <person>|<title>|<content>")
return True
person_name, title, content = (
parts[0].title(),
parts[1],
"|".join(parts[2:]),
)
person = await sync_to_async(
lambda: Person.objects.filter(
user=sender_user, name__iexact=person_name
).first()
)()
if not person:
sym("Unknown person.")
return True
plan = await sync_to_async(self._get_or_create_plan)(sender_user, person)
await sync_to_async(PatternMitigationRule.objects.create)(
user=sender_user,
plan=plan,
title=title[:255],
content=content,
enabled=True,
)
sym("Rule added.")
return True
if command.startswith(".mitigation rule-del "):
payload = command.replace(".mitigation rule-del ", "", 1)
parts = parse_parts(payload)
if len(parts) < 2:
sym("Usage: .mitigation rule-del <person>|<title>")
return True
person_name, title = parts[0].title(), "|".join(parts[1:])
person = await sync_to_async(
lambda: Person.objects.filter(
user=sender_user, name__iexact=person_name
).first()
)()
if not person:
sym("Unknown person.")
return True
plan = await sync_to_async(self._get_or_create_plan)(sender_user, person)
deleted, _ = await sync_to_async(
lambda: PatternMitigationRule.objects.filter(
user=sender_user,
plan=plan,
title__iexact=title,
).delete()
)()
sym("Rule deleted." if deleted else "Rule not found.")
return True
if command.startswith(".mitigation game-add "):
payload = command.replace(".mitigation game-add ", "", 1)
parts = parse_parts(payload)
if len(parts) < 3:
sym("Usage: .mitigation game-add <person>|<title>|<instructions>")
return True
person_name, title, content = (
parts[0].title(),
parts[1],
"|".join(parts[2:]),
)
person = await sync_to_async(
lambda: Person.objects.filter(
user=sender_user, name__iexact=person_name
).first()
)()
if not person:
sym("Unknown person.")
return True
plan = await sync_to_async(self._get_or_create_plan)(sender_user, person)
await sync_to_async(PatternMitigationGame.objects.create)(
user=sender_user,
plan=plan,
title=title[:255],
instructions=content,
enabled=True,
)
sym("Game added.")
return True
if command.startswith(".mitigation game-del "):
payload = command.replace(".mitigation game-del ", "", 1)
parts = parse_parts(payload)
if len(parts) < 2:
sym("Usage: .mitigation game-del <person>|<title>")
return True
person_name, title = parts[0].title(), "|".join(parts[1:])
person = await sync_to_async(
lambda: Person.objects.filter(
user=sender_user, name__iexact=person_name
).first()
)()
if not person:
sym("Unknown person.")
return True
plan = await sync_to_async(self._get_or_create_plan)(sender_user, person)
deleted, _ = await sync_to_async(
lambda: PatternMitigationGame.objects.filter(
user=sender_user,
plan=plan,
title__iexact=title,
).delete()
)()
sym("Game deleted." if deleted else "Game not found.")
return True
if command.startswith(".mitigation correction-add "):
payload = command.replace(".mitigation correction-add ", "", 1)
parts = parse_parts(payload)
if len(parts) < 3:
sym(
"Usage: .mitigation correction-add <person>|<title>|<clarification>"
)
return True
person_name, title, clarification = (
parts[0].title(),
parts[1],
"|".join(parts[2:]),
)
person = await sync_to_async(
lambda: Person.objects.filter(
user=sender_user, name__iexact=person_name
).first()
)()
if not person:
sym("Unknown person.")
return True
plan = await sync_to_async(self._get_or_create_plan)(sender_user, person)
await sync_to_async(PatternMitigationCorrection.objects.create)(
user=sender_user,
plan=plan,
title=title[:255],
clarification=clarification,
source_phrase="",
perspective="second_person",
share_target="both",
language_style="adapted",
enabled=True,
)
sym("Correction added.")
return True
if command.startswith(".mitigation correction-del "):
payload = command.replace(".mitigation correction-del ", "", 1)
parts = parse_parts(payload)
if len(parts) < 2:
sym("Usage: .mitigation correction-del <person>|<title>")
return True
person_name, title = parts[0].title(), "|".join(parts[1:])
person = await sync_to_async(
lambda: Person.objects.filter(
user=sender_user, name__iexact=person_name
).first()
)()
if not person:
sym("Unknown person.")
return True
plan = await sync_to_async(self._get_or_create_plan)(sender_user, person)
deleted, _ = await sync_to_async(
lambda: PatternMitigationCorrection.objects.filter(
user=sender_user,
plan=plan,
title__iexact=title,
).delete()
)()
sym("Correction deleted." if deleted else "Correction not found.")
return True
if command.startswith(".mitigation fundamentals-set "):
payload = command.replace(".mitigation fundamentals-set ", "", 1)
parts = parse_parts(payload)
if len(parts) < 2:
sym("Usage: .mitigation fundamentals-set <person>|<item1;item2;...>")
return True
person_name, values = parts[0].title(), "|".join(parts[1:])
person = await sync_to_async(
lambda: Person.objects.filter(
user=sender_user, name__iexact=person_name
).first()
)()
if not person:
sym("Unknown person.")
return True
plan = await sync_to_async(self._get_or_create_plan)(sender_user, person)
items = [item.strip() for item in values.split(";") if item.strip()]
plan.fundamental_items = items
await sync_to_async(plan.save)(
update_fields=["fundamental_items", "updated_at"]
)
sym(f"Fundamentals updated ({len(items)}).")
return True
if command.startswith(".mitigation plan-set "):
payload = command.replace(".mitigation plan-set ", "", 1)
parts = parse_parts(payload)
if len(parts) < 3:
sym(
"Usage: .mitigation plan-set <person>|<draft|active|archived>|<auto|guided>"
)
return True
person_name, status_value, mode_value = (
parts[0].title(),
parts[1].lower(),
parts[2].lower(),
)
person = await sync_to_async(
lambda: Person.objects.filter(
user=sender_user, name__iexact=person_name
).first()
)()
if not person:
sym("Unknown person.")
return True
plan = await sync_to_async(self._get_or_create_plan)(sender_user, person)
valid_status = {key for key, _ in PatternMitigationPlan.STATUS_CHOICES}
valid_modes = {
key for key, _ in PatternMitigationPlan.CREATION_MODE_CHOICES
}
if status_value in valid_status:
plan.status = status_value
if mode_value in valid_modes:
plan.creation_mode = mode_value
await sync_to_async(plan.save)(
update_fields=["status", "creation_mode", "updated_at"]
)
sym(f"Plan updated: status={plan.status}, mode={plan.creation_mode}")
return True
if command.startswith(".mitigation auto "):
payload = command.replace(".mitigation auto ", "", 1)
parts = parse_parts(payload)
if len(parts) < 2:
sym("Usage: .mitigation auto <person>|on|off")
return True
person_name, state = parts[0].title(), parts[1].lower()
person = await sync_to_async(
lambda: Person.objects.filter(
user=sender_user, name__iexact=person_name
).first()
)()
if not person:
sym("Unknown person.")
return True
conversation = await sync_to_async(self._get_workspace_conversation)(
sender_user, person
)
auto_obj, _ = await sync_to_async(
PatternMitigationAutoSettings.objects.get_or_create
)(
user=sender_user,
conversation=conversation,
)
auto_obj.enabled = state in {"on", "true", "1", "yes"}
await sync_to_async(auto_obj.save)(update_fields=["enabled", "updated_at"])
sym(
f"Automation {'enabled' if auto_obj.enabled else 'disabled'} for {person.name}."
)
return True
if command.startswith(".mitigation auto-status "):
person_name = (
command.replace(".mitigation auto-status ", "", 1).strip().title()
)
person = await sync_to_async(
lambda: Person.objects.filter(
user=sender_user, name__iexact=person_name
).first()
)()
if not person:
sym("Unknown person.")
return True
conversation = await sync_to_async(self._get_workspace_conversation)(
sender_user, person
)
auto_obj, _ = await sync_to_async(
PatternMitigationAutoSettings.objects.get_or_create
)(
user=sender_user,
conversation=conversation,
)
sym(
f"{person.name}: auto={'on' if auto_obj.enabled else 'off'}, "
f"pattern={'on' if auto_obj.auto_pattern_recognition else 'off'}, "
f"corrections={'on' if auto_obj.auto_create_corrections else 'off'}"
)
return True
return False
def update_roster(self, jid, name=None):
"""
Adds or updates a user in the roster.
"""
iq = self.Iq()
iq["type"] = "set"
iq["roster"]["items"] = {jid: {"name": name or jid}}
iq.send()
self.log.debug("Updated roster: added %s (%s)", jid, name)
def on_chatstate_active(self, msg):
"""
Handle when a user is actively engaged in the chat.
"""
self.log.debug("Chat state active from %s", msg["from"])
self.get_identifier(msg)
def on_chatstate_composing(self, msg):
"""
Handle when a user is typing a message.
"""
self.log.debug("Chat state composing from %s", msg["from"])
identifier = self.get_identifier(msg)
if identifier:
asyncio.create_task(
self.ur.started_typing(
"xmpp",
identifier=identifier,
)
)
def on_chatstate_paused(self, msg):
"""
Handle when a user has paused typing.
"""
self.log.debug("Chat state paused from %s", msg["from"])
identifier = self.get_identifier(msg)
if identifier:
asyncio.create_task(
self.ur.stopped_typing(
"xmpp",
identifier=identifier,
)
)
def on_chatstate_inactive(self, msg):
"""
Handle when a user is inactive in the chat.
"""
self.log.debug("Chat state inactive from %s", msg["from"])
self.get_identifier(msg)
def on_chatstate_gone(self, msg):
"""
Handle when a user has left the chat.
"""
self.log.debug("Chat state gone from %s", msg["from"])
self.get_identifier(msg)
def on_presence_available(self, pres):
"""
Handle when a user becomes available.
"""
self.log.debug("Presence available from %s", pres["from"])
def on_presence_dnd(self, pres):
"""
Handle when a user sets 'Do Not Disturb' status.
"""
self.log.debug("Presence dnd from %s", pres["from"])
def on_presence_xa(self, pres):
"""
Handle when a user sets 'Extended Away' status.
"""
self.log.debug("Presence extended-away from %s", pres["from"])
def on_presence_chat(self, pres):
"""
Handle when a user is actively available for chat.
"""
self.log.debug("Presence chat-available from %s", pres["from"])
def on_presence_away(self, pres):
"""
Handle when a user sets 'Away' status.
"""
self.log.debug("Presence away from %s", pres["from"])
def on_presence_unavailable(self, pres):
"""
Handle when a user goes offline or unavailable.
"""
self.log.debug("Presence unavailable from %s", pres["from"])
def on_presence_subscribe(self, pres):
"""
Handle incoming presence subscription requests.
Accept 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)