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