2548 lines
100 KiB
Python
2548 lines
100 KiB
Python
import asyncio
|
||
import base64
|
||
import json
|
||
import mimetypes
|
||
import os
|
||
import re
|
||
import time
|
||
import uuid
|
||
from pathlib import Path
|
||
from urllib.parse import parse_qs, urlparse, urlsplit
|
||
|
||
import aiohttp
|
||
from asgiref.sync import sync_to_async
|
||
from django.conf import settings
|
||
from django.utils.timezone import now
|
||
from slixmpp.componentxmpp import ComponentXMPP
|
||
from slixmpp.plugins.xep_0085.stanza import Active, Composing, Gone, Inactive, Paused
|
||
from slixmpp.stanza import Message
|
||
from slixmpp.xmlstream import register_stanza_plugin
|
||
from slixmpp.xmlstream.stanzabase import ET
|
||
|
||
from core.clients import ClientBase, transport
|
||
from core.gateway.commands import (
|
||
GatewayCommandContext,
|
||
GatewayCommandRoute,
|
||
dispatch_gateway_command,
|
||
)
|
||
from core.messaging import ai, history, replies, reply_sync, utils
|
||
from core.models import (
|
||
ChatSession,
|
||
CodexPermissionRequest,
|
||
CodexRun,
|
||
DerivedTask,
|
||
ExternalSyncEvent,
|
||
Manipulation,
|
||
PatternMitigationAutoSettings,
|
||
PatternMitigationCorrection,
|
||
PatternMitigationGame,
|
||
PatternMitigationPlan,
|
||
PatternMitigationRule,
|
||
Person,
|
||
PersonIdentifier,
|
||
User,
|
||
UserXmppOmemoState,
|
||
WorkspaceConversation,
|
||
)
|
||
from core.security.attachments import (
|
||
validate_attachment_metadata,
|
||
validate_attachment_url,
|
||
)
|
||
from core.util import logs
|
||
|
||
URL_PATTERN = re.compile(r"https?://[^\s<>'\"\\]+")
|
||
EMOJI_ONLY_PATTERN = re.compile(
|
||
r"^[\U0001F300-\U0001FAFF\u2600-\u27BF\uFE0F\u200D\u2640-\u2642\u2764]+$"
|
||
)
|
||
TOTP_BASE32_SECRET_RE = re.compile(r"^[A-Z2-7]{16,}$")
|
||
|
||
|
||
def _clean_url(value):
|
||
return str(value or "").strip().rstrip(".,);:!?\"'")
|
||
|
||
|
||
def _filename_from_url(url_value):
|
||
path = urlsplit(str(url_value or "")).path
|
||
name = path.rsplit("/", 1)[-1]
|
||
return name or "attachment"
|
||
|
||
|
||
def _content_type_from_filename_or_url(url_value, default="application/octet-stream"):
|
||
_ = url_value
|
||
return str(default or "application/octet-stream")
|
||
|
||
|
||
def _extract_xml_attachment_urls(message_stanza):
|
||
urls = []
|
||
|
||
def _add(candidate):
|
||
cleaned = _clean_url(candidate)
|
||
if not cleaned:
|
||
return
|
||
if not cleaned.startswith("http://") and not cleaned.startswith("https://"):
|
||
return
|
||
if cleaned not in urls:
|
||
urls.append(cleaned)
|
||
|
||
# Explicit attachments and OOB payloads.
|
||
for node in message_stanza.xml.findall(".//{urn:xmpp:attachments}attachment"):
|
||
_add(node.attrib.get("url"))
|
||
for node in message_stanza.xml.findall(".//{jabber:x:oob}x/{jabber:x:oob}url"):
|
||
_add(node.text)
|
||
|
||
# XMPP references frequently carry attachment URIs.
|
||
for node in message_stanza.xml.findall(".//{urn:xmpp:reference:0}reference"):
|
||
_add(node.attrib.get("uri"))
|
||
|
||
# Generic fallback for custom namespaces and rich message payloads.
|
||
for node in message_stanza.xml.iter():
|
||
for key in ("url", "uri", "href", "src"):
|
||
_add(node.attrib.get(key))
|
||
for match in URL_PATTERN.findall(str(node.text or "")):
|
||
_add(match)
|
||
|
||
return urls
|
||
|
||
|
||
def _extract_xmpp_reaction(message_stanza):
|
||
nodes = message_stanza.xml.findall(".//{urn:xmpp:reactions:0}reactions")
|
||
if not nodes:
|
||
return None
|
||
node = nodes[0]
|
||
target_id = str(node.attrib.get("id") or "").strip()
|
||
emojis = []
|
||
for child in node.findall("{urn:xmpp:reactions:0}reaction"):
|
||
value = str(child.text or "").strip()
|
||
if value:
|
||
emojis.append(value)
|
||
return {
|
||
"target_id": target_id,
|
||
"emoji": emojis[0] if emojis else "",
|
||
"remove": len(emojis) == 0,
|
||
}
|
||
|
||
|
||
def _extract_xmpp_reply_target_id(message_stanza):
|
||
reply = message_stanza.xml.find(".//{urn:xmpp:reply:0}reply")
|
||
if reply is None:
|
||
return ""
|
||
return str(reply.attrib.get("id") or reply.attrib.get("to") or "").strip()
|
||
|
||
|
||
def _parse_greentext_reaction(body_text):
|
||
lines = [line.strip() for line in str(body_text or "").splitlines() if line.strip()]
|
||
if len(lines) != 2:
|
||
return None
|
||
if not lines[0].startswith(">"):
|
||
return None
|
||
quoted = lines[0][1:].strip()
|
||
emoji = lines[1].strip()
|
||
if not quoted or not emoji:
|
||
return None
|
||
if not EMOJI_ONLY_PATTERN.match(emoji):
|
||
return None
|
||
return {"quoted_text": quoted, "emoji": emoji}
|
||
|
||
|
||
def _omemo_plugin_available() -> bool:
|
||
try:
|
||
import importlib
|
||
return importlib.util.find_spec("slixmpp_omemo") is not None
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def _extract_sender_omemo_client_key(stanza) -> dict:
|
||
"""Extract OMEMO client key info from an encrypted stanza."""
|
||
ns = "eu.siacs.conversations.axolotl"
|
||
header = stanza.xml.find(f".//{{{ns}}}header")
|
||
if header is None:
|
||
return {"status": "no_omemo"}
|
||
sid = str(header.attrib.get("sid") or "").strip()
|
||
key_el = header.find(f"{{{ns}}}key")
|
||
rid = str(key_el.attrib.get("rid") or "").strip() if key_el is not None else ""
|
||
if sid or rid:
|
||
return {"status": "detected", "client_key": f"sid:{sid},rid:{rid}"}
|
||
return {"status": "no_omemo"}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# OMEMO storage + plugin implementation
|
||
# ---------------------------------------------------------------------------
|
||
|
||
try:
|
||
from omemo.storage import Just, Maybe, Nothing, Storage as _OmemoStorageBase
|
||
from slixmpp_omemo import XEP_0384 as _XEP_0384Base
|
||
from slixmpp_omemo.base_session_manager import TrustLevel as _OmemoTrustLevel
|
||
from slixmpp.plugins.base import register_plugin as _slixmpp_register_plugin
|
||
_OMEMO_AVAILABLE = True
|
||
except ImportError:
|
||
_OMEMO_AVAILABLE = False
|
||
_OmemoStorageBase = object
|
||
_XEP_0384Base = object
|
||
_OmemoTrustLevel = None
|
||
_slixmpp_register_plugin = None
|
||
|
||
|
||
if _OMEMO_AVAILABLE:
|
||
class _OmemoStorage(_OmemoStorageBase):
|
||
"""JSON-file-backed OMEMO key storage."""
|
||
|
||
def __init__(self, path: str) -> None:
|
||
super().__init__()
|
||
self._path = path
|
||
try:
|
||
with open(path) as f:
|
||
self._data: dict = json.load(f)
|
||
except (FileNotFoundError, json.JSONDecodeError):
|
||
self._data = {}
|
||
|
||
def _save(self) -> None:
|
||
os.makedirs(os.path.dirname(self._path), exist_ok=True)
|
||
with open(self._path, "w") as f:
|
||
json.dump(self._data, f)
|
||
|
||
async def _load(self, key: str) -> Maybe:
|
||
if key in self._data:
|
||
return Just(self._data[key])
|
||
return Nothing()
|
||
|
||
async def _store(self, key: str, value) -> None:
|
||
self._data[key] = value
|
||
self._save()
|
||
|
||
async def _delete(self, key: str) -> None:
|
||
self._data.pop(key, None)
|
||
self._save()
|
||
|
||
class _GiaOmemoPlugin(_XEP_0384Base):
|
||
"""Concrete XEP-0384 OMEMO plugin for the GIA XMPP gateway component.
|
||
|
||
Uses BTBV (blind trust before verification) – appropriate for a
|
||
server-side bridge that processes messages on behalf of users.
|
||
"""
|
||
|
||
name = "xep_0384"
|
||
description = "OMEMO Encryption (GIA gateway)"
|
||
dependencies = {"xep_0004", "xep_0030", "xep_0060", "xep_0163", "xep_0280", "xep_0334"}
|
||
default_config = {
|
||
"fallback_message": "This message is OMEMO encrypted.",
|
||
"data_dir": "",
|
||
}
|
||
|
||
def plugin_init(self) -> None:
|
||
data_dir = str(self.config.get("data_dir") or "").strip()
|
||
if not data_dir:
|
||
data_dir = str(Path(settings.BASE_DIR) / "xmpp_omemo_data")
|
||
os.makedirs(data_dir, exist_ok=True)
|
||
self._storage_impl = _OmemoStorage(os.path.join(data_dir, "omemo.json"))
|
||
super().plugin_init()
|
||
|
||
@property
|
||
def storage(self) -> _OmemoStorageBase:
|
||
return self._storage_impl
|
||
|
||
@property
|
||
def _btbv_enabled(self) -> bool:
|
||
return True
|
||
|
||
async def _devices_blindly_trusted(self, blindly_trusted, identifier):
|
||
import logging
|
||
logging.getLogger(__name__).info(
|
||
"OMEMO: blindly trusted %d new device(s)", len(blindly_trusted)
|
||
)
|
||
|
||
async def _prompt_manual_trust(self, manually_trusted, identifier):
|
||
"""Auto-trust all undecided devices (gateway mode)."""
|
||
import logging
|
||
log = logging.getLogger(__name__)
|
||
log.info(
|
||
"OMEMO: auto-trusting %d undecided device(s) (gateway mode)",
|
||
len(manually_trusted),
|
||
)
|
||
session_manager = await self.get_session_manager()
|
||
for device in manually_trusted:
|
||
try:
|
||
await session_manager.set_trust(
|
||
device.bare_jid,
|
||
device.device_id,
|
||
device.identity_key,
|
||
_OmemoTrustLevel.BLINDLY_TRUSTED.value,
|
||
)
|
||
except Exception as exc:
|
||
log.warning("OMEMO set_trust failed for %s: %s", device.bare_jid, exc)
|
||
|
||
|
||
class XMPPComponent(ComponentXMPP):
|
||
|
||
"""
|
||
A simple Slixmpp component that echoes messages.
|
||
"""
|
||
|
||
def __init__(self, ur, jid, secret, server, port):
|
||
self.ur = ur
|
||
self._upload_config_warned = False
|
||
self._reconnect_task = None
|
||
self._reconnect_delay_seconds = 1.0
|
||
self._reconnect_delay_max_seconds = 30.0
|
||
self._connect_inflight = False
|
||
self._session_live = False
|
||
|
||
self.log = logs.get_logger("XMPP")
|
||
|
||
super().__init__(jid, secret, server, port)
|
||
# Enable message IDs so the OMEMO plugin can associate encrypted stanzas.
|
||
self.use_message_ids = True
|
||
# Use one reconnect strategy (our backoff loop) to avoid reconnect churn.
|
||
self.auto_reconnect = False
|
||
# Register chat state plugins
|
||
register_stanza_plugin(Message, Active)
|
||
register_stanza_plugin(Message, Composing)
|
||
register_stanza_plugin(Message, Paused)
|
||
register_stanza_plugin(Message, Inactive)
|
||
register_stanza_plugin(Message, Gone)
|
||
|
||
self.add_event_handler("session_start", self.session_start)
|
||
self.add_event_handler("disconnected", self.on_disconnected)
|
||
self.add_event_handler("message", self.message)
|
||
|
||
# Presence event handlers
|
||
self.add_event_handler("presence_available", self.on_presence_available)
|
||
self.add_event_handler("presence_dnd", self.on_presence_dnd)
|
||
self.add_event_handler("presence_xa", self.on_presence_xa)
|
||
self.add_event_handler("presence_chat", self.on_presence_chat)
|
||
self.add_event_handler("presence_away", self.on_presence_away)
|
||
self.add_event_handler("presence_unavailable", self.on_presence_unavailable)
|
||
self.add_event_handler("presence_subscribe", self.on_presence_subscribe)
|
||
self.add_event_handler("presence_subscribed", self.on_presence_subscribed)
|
||
self.add_event_handler("presence_unsubscribe", self.on_presence_unsubscribe)
|
||
self.add_event_handler("presence_unsubscribed", self.on_presence_unsubscribed)
|
||
self.add_event_handler(
|
||
"roster_subscription_request", self.on_roster_subscription_request
|
||
)
|
||
|
||
# Chat state handlers
|
||
self.add_event_handler("chatstate_active", self.on_chatstate_active)
|
||
self.add_event_handler("chatstate_composing", self.on_chatstate_composing)
|
||
self.add_event_handler("chatstate_paused", self.on_chatstate_paused)
|
||
self.add_event_handler("chatstate_inactive", self.on_chatstate_inactive)
|
||
self.add_event_handler("chatstate_gone", self.on_chatstate_gone)
|
||
|
||
def _user_xmpp_domain(self):
|
||
domain = str(getattr(settings, "XMPP_USER_DOMAIN", "") or "").strip()
|
||
if domain:
|
||
return domain
|
||
component_jid = str(getattr(settings, "XMPP_JID", "") or "").strip()
|
||
if "." in component_jid:
|
||
return component_jid.split(".", 1)[1]
|
||
configured_domain = str(getattr(settings, "DOMAIN", "") or "").strip()
|
||
if configured_domain:
|
||
return configured_domain
|
||
return str(getattr(settings, "XMPP_ADDRESS", "") or "").strip()
|
||
|
||
def _user_jid(self, username):
|
||
return f"{username}@{self._user_xmpp_domain()}"
|
||
|
||
async def enable_carbons(self):
|
||
"""Enable XMPP Message Carbons (XEP-0280)"""
|
||
try:
|
||
iq = self.make_iq_set()
|
||
iq["enable"] = ET.Element("{urn:xmpp:carbons:2}enable")
|
||
await iq.send()
|
||
self.log.info("Message Carbons enabled successfully")
|
||
except Exception as e:
|
||
self.log.error(f"Failed to enable Carbons: {e}")
|
||
|
||
def get_identifier(self, msg):
|
||
xmpp_message_id = str(msg.get("id") or "").strip()
|
||
|
||
# Extract sender JID (full format: user@domain/resource)
|
||
sender_jid = str(msg["from"])
|
||
|
||
# Split into username@domain and optional resource
|
||
sender_parts = sender_jid.split("/", 1)
|
||
sender_bare_jid = sender_parts[0] # Always present: user@domain
|
||
sender_username, sender_domain = sender_bare_jid.split("@", 1)
|
||
|
||
# Extract recipient JID (should match component JID format)
|
||
recipient_jid = str(msg["to"])
|
||
|
||
if "@" in recipient_jid:
|
||
recipient_username = recipient_jid.split("@", 1)[0]
|
||
else:
|
||
recipient_username = recipient_jid
|
||
# Parse recipient_name and recipient_service (e.g., "mark|signal")
|
||
if "|" in recipient_username:
|
||
person_name, service = recipient_username.split("|")
|
||
person_name = person_name.title() # Capitalize for consistency
|
||
else:
|
||
person_name = recipient_username.title()
|
||
service = None
|
||
|
||
try:
|
||
# Lookup user in Django
|
||
self.log.debug("Resolving XMPP sender user=%s", sender_username)
|
||
user = User.objects.get(username=sender_username)
|
||
|
||
# Find Person object with name=person_name.lower()
|
||
self.log.debug("Resolving XMPP recipient person=%s", person_name.title())
|
||
person = Person.objects.get(user=user, name=person_name.title())
|
||
|
||
# Ensure a PersonIdentifier exists for this user, person, and service
|
||
self.log.debug("Resolving XMPP identifier service=%s", service)
|
||
identifier = PersonIdentifier.objects.get(
|
||
user=user, person=person, service=service
|
||
)
|
||
return identifier
|
||
except Exception as e:
|
||
self.log.error(f"Failed to resolve identifier from XMPP message: {e}")
|
||
return None
|
||
|
||
def _get_workspace_conversation(self, user, person):
|
||
primary_identifier = (
|
||
PersonIdentifier.objects.filter(user=user, person=person)
|
||
.order_by("service")
|
||
.first()
|
||
)
|
||
platform_type = primary_identifier.service if primary_identifier else "signal"
|
||
conversation, _ = WorkspaceConversation.objects.get_or_create(
|
||
user=user,
|
||
platform_type=platform_type,
|
||
title=f"{person.name} Workspace",
|
||
defaults={"platform_thread_id": str(person.id)},
|
||
)
|
||
conversation.participants.add(person)
|
||
return conversation
|
||
|
||
def _get_or_create_plan(self, user, person):
|
||
conversation = self._get_workspace_conversation(user, person)
|
||
plan = conversation.mitigation_plans.order_by("-updated_at").first()
|
||
if plan is None:
|
||
plan = PatternMitigationPlan.objects.create(
|
||
user=user,
|
||
conversation=conversation,
|
||
title=f"{person.name} Pattern Mitigation",
|
||
objective="Mitigate repeated friction loops.",
|
||
fundamental_items=[],
|
||
creation_mode="guided",
|
||
status="draft",
|
||
)
|
||
PatternMitigationRule.objects.create(
|
||
user=user,
|
||
plan=plan,
|
||
title="Safety Before Analysis",
|
||
content="Prioritize de-escalation before analysis.",
|
||
enabled=True,
|
||
)
|
||
PatternMitigationGame.objects.create(
|
||
user=user,
|
||
plan=plan,
|
||
title="Two-Turn Pause",
|
||
instructions="Use two short turns then pause with a return time.",
|
||
enabled=True,
|
||
)
|
||
return plan
|
||
|
||
def _derived_omemo_fingerprint(self, jid: str) -> str:
|
||
import hashlib
|
||
return hashlib.sha256(f"xmpp-omemo-key:{jid}".encode()).hexdigest()[:32]
|
||
|
||
def _get_omemo_plugin(self):
|
||
"""Return the active XEP-0384 plugin instance, or None if not loaded."""
|
||
try:
|
||
return self["xep_0384"]
|
||
except Exception:
|
||
return None
|
||
|
||
async def _bootstrap_omemo_for_authentic_channel(self):
|
||
jid = str(getattr(settings, "XMPP_JID", "") or "").strip()
|
||
omemo_plugin = self._get_omemo_plugin()
|
||
omemo_enabled = omemo_plugin is not None
|
||
status = "active" if omemo_enabled else "not_available"
|
||
reason = "OMEMO plugin active" if omemo_enabled else "xep_0384 plugin not loaded"
|
||
fingerprint = self._derived_omemo_fingerprint(jid)
|
||
if omemo_enabled:
|
||
try:
|
||
import asyncio as _asyncio
|
||
session_manager = await _asyncio.wait_for(
|
||
omemo_plugin.get_session_manager(), timeout=15.0
|
||
)
|
||
own_devices = await session_manager.get_own_device_information()
|
||
if own_devices:
|
||
key_bytes = own_devices[0].identity_key
|
||
fingerprint = ":".join(f"{b:02X}" for b in key_bytes)
|
||
except Exception as exc:
|
||
self.log.warning("OMEMO: could not read own device fingerprint: %s", exc)
|
||
self.log.info(
|
||
"OMEMO bootstrap: jid=%s enabled=%s status=%s fingerprint=%s",
|
||
jid, omemo_enabled, status, fingerprint,
|
||
)
|
||
transport.update_runtime_state(
|
||
"xmpp",
|
||
omemo_target_jid=jid,
|
||
omemo_fingerprint=fingerprint,
|
||
omemo_enabled=omemo_enabled,
|
||
omemo_status=status,
|
||
omemo_status_reason=reason,
|
||
)
|
||
|
||
async def _record_sender_omemo_state(self, user, *, sender_jid, recipient_jid, message_stanza):
|
||
parsed = _extract_sender_omemo_client_key(message_stanza)
|
||
status = str(parsed.get("status") or "no_omemo")
|
||
client_key = str(parsed.get("client_key") or "")
|
||
await sync_to_async(UserXmppOmemoState.objects.update_or_create)(
|
||
user=user,
|
||
defaults={
|
||
"status": status,
|
||
"latest_client_key": client_key,
|
||
"last_sender_jid": str(sender_jid or ""),
|
||
"last_target_jid": str(recipient_jid or ""),
|
||
},
|
||
)
|
||
|
||
_approval_event_prefix = "codex_approval"
|
||
|
||
_APPROVAL_PROVIDER_COMMANDS = {
|
||
".claude": "claude",
|
||
".codex": "codex_cli",
|
||
}
|
||
|
||
def _resolve_request_provider(self, request):
|
||
event = getattr(request, "external_sync_event", None)
|
||
if event is None:
|
||
return ""
|
||
return str(getattr(event, "provider", "") or "").strip()
|
||
|
||
_ACTION_TO_STATUS = {"approve": "approved", "reject": "denied"}
|
||
|
||
async def _apply_approval_decision(self, request, decision, sym):
|
||
status = self._ACTION_TO_STATUS.get(decision, decision)
|
||
request.status = status
|
||
await sync_to_async(request.save)(update_fields=["status"])
|
||
run = None
|
||
if request.codex_run_id:
|
||
run = await sync_to_async(CodexRun.objects.get)(pk=request.codex_run_id)
|
||
run.status = "approved_waiting_resume" if status == "approved" else status
|
||
await sync_to_async(run.save)(update_fields=["status"])
|
||
if request.external_sync_event_id:
|
||
evt = await sync_to_async(ExternalSyncEvent.objects.get)(pk=request.external_sync_event_id)
|
||
evt.status = "ok"
|
||
await sync_to_async(evt.save)(update_fields=["status"])
|
||
user = await sync_to_async(User.objects.get)(pk=request.user_id)
|
||
task = None
|
||
if run is not None and run.task_id:
|
||
task = await sync_to_async(DerivedTask.objects.get)(pk=run.task_id)
|
||
ikey = f"{self._approval_event_prefix}:{request.approval_key}:{status}"
|
||
await sync_to_async(ExternalSyncEvent.objects.get_or_create)(
|
||
idempotency_key=ikey,
|
||
defaults={
|
||
"user": user,
|
||
"task": task,
|
||
"provider": "codex_cli",
|
||
"status": "pending",
|
||
"payload": {},
|
||
"error": "",
|
||
},
|
||
)
|
||
|
||
async def _approval_list_pending(self, user, scope, sym):
|
||
requests = await sync_to_async(list)(
|
||
CodexPermissionRequest.objects.filter(
|
||
user=user, status="pending"
|
||
).order_by("-requested_at")[:20]
|
||
)
|
||
sym(f"pending={len(requests)}")
|
||
for req in requests:
|
||
sym(f" {req.approval_key}: {req.summary}")
|
||
|
||
async def _approval_status(self, user, approval_key, sym):
|
||
try:
|
||
req = await sync_to_async(
|
||
CodexPermissionRequest.objects.get
|
||
)(user=user, approval_key=approval_key)
|
||
sym(f"status={req.status} key={req.approval_key}")
|
||
except CodexPermissionRequest.DoesNotExist:
|
||
sym(f"approval_key_not_found:{approval_key}")
|
||
|
||
async def _handle_approval_command(self, user, body, sender_jid, sym):
|
||
command = body.strip()
|
||
for prefix, expected_provider in self._APPROVAL_PROVIDER_COMMANDS.items():
|
||
if command.startswith(prefix + " ") or command == prefix:
|
||
sub = command[len(prefix):].strip()
|
||
parts = sub.split()
|
||
if len(parts) >= 2 and parts[0] in ("approve", "reject"):
|
||
action, approval_key = parts[0], parts[1]
|
||
try:
|
||
req = await sync_to_async(
|
||
CodexPermissionRequest.objects.select_related(
|
||
"external_sync_event"
|
||
).get
|
||
)(user=user, approval_key=approval_key)
|
||
except CodexPermissionRequest.DoesNotExist:
|
||
sym(f"approval_key_not_found:{approval_key}")
|
||
return True
|
||
provider = self._resolve_request_provider(req)
|
||
if not provider.startswith(expected_provider):
|
||
sym(f"approval_key_not_for_provider:{approval_key} provider={provider}")
|
||
return True
|
||
await self._apply_approval_decision(req, action, sym)
|
||
sym(f"{action}d: {approval_key}")
|
||
return True
|
||
sym(f"usage: {prefix} approve|reject <key>")
|
||
return True
|
||
|
||
if not command.startswith(".approval"):
|
||
return False
|
||
|
||
rest = command[len(".approval"):].strip()
|
||
|
||
if rest.split() and rest.split()[0] in ("approve", "reject"):
|
||
parts = rest.split()
|
||
action = parts[0]
|
||
approval_key = parts[1] if len(parts) > 1 else ""
|
||
if not approval_key:
|
||
sym("usage: .approval approve|reject <key>")
|
||
return True
|
||
try:
|
||
req = await sync_to_async(
|
||
CodexPermissionRequest.objects.select_related(
|
||
"external_sync_event"
|
||
).get
|
||
)(user=user, approval_key=approval_key)
|
||
except CodexPermissionRequest.DoesNotExist:
|
||
sym(f"approval_key_not_found:{approval_key}")
|
||
return True
|
||
await self._apply_approval_decision(req, action, sym)
|
||
sym(f"{action}d: {approval_key}")
|
||
return True
|
||
|
||
if rest.startswith("list-pending"):
|
||
scope = rest[len("list-pending"):].strip() or "mine"
|
||
await self._approval_list_pending(user, scope, sym)
|
||
return True
|
||
|
||
if rest.startswith("status "):
|
||
approval_key = rest[len("status "):].strip()
|
||
await self._approval_status(user, approval_key, sym)
|
||
return True
|
||
|
||
sym(
|
||
"approval: .approval approve|reject <key> | "
|
||
".approval list-pending [all] | "
|
||
".approval status <key>"
|
||
)
|
||
return True
|
||
|
||
async def _handle_tasks_command(self, user, body, sym):
|
||
command = body.strip()
|
||
if not command.startswith(".tasks"):
|
||
return False
|
||
rest = command[len(".tasks"):].strip()
|
||
|
||
if rest.startswith("list"):
|
||
parts = rest.split()
|
||
status_filter = parts[1] if len(parts) > 1 else "open"
|
||
limit = int(parts[2]) if len(parts) > 2 and parts[2].isdigit() else 10
|
||
tasks = await sync_to_async(list)(
|
||
DerivedTask.objects.filter(
|
||
user=user, status_snapshot=status_filter
|
||
).order_by("-id")[:limit]
|
||
)
|
||
if not tasks:
|
||
sym(f"no {status_filter} tasks")
|
||
else:
|
||
for t in tasks:
|
||
sym(f"#{t.reference_code} [{t.status_snapshot}] {t.title}")
|
||
return True
|
||
|
||
if rest.startswith("show "):
|
||
ref = rest[len("show "):].strip().lstrip("#")
|
||
try:
|
||
task = await sync_to_async(DerivedTask.objects.get)(
|
||
user=user, reference_code=ref
|
||
)
|
||
sym(f"#{task.reference_code} {task.title}")
|
||
sym(f"status: {task.status_snapshot}")
|
||
except DerivedTask.DoesNotExist:
|
||
sym(f"task_not_found:#{ref}")
|
||
return True
|
||
|
||
if rest.startswith("complete "):
|
||
ref = rest[len("complete "):].strip().lstrip("#")
|
||
try:
|
||
task = await sync_to_async(DerivedTask.objects.get)(
|
||
user=user, reference_code=ref
|
||
)
|
||
task.status_snapshot = "completed"
|
||
await sync_to_async(task.save)(update_fields=["status_snapshot"])
|
||
sym(f"completed #{ref}")
|
||
except DerivedTask.DoesNotExist:
|
||
sym(f"task_not_found:#{ref}")
|
||
return True
|
||
|
||
if rest.startswith("undo "):
|
||
ref = rest[len("undo "):].strip().lstrip("#")
|
||
try:
|
||
task = await sync_to_async(DerivedTask.objects.get)(
|
||
user=user, reference_code=ref
|
||
)
|
||
await sync_to_async(task.delete)()
|
||
sym(f"removed #{ref}")
|
||
except DerivedTask.DoesNotExist:
|
||
sym(f"task_not_found:#{ref}")
|
||
return True
|
||
|
||
sym(
|
||
"tasks: .tasks list [status] [limit] | "
|
||
".tasks show #<ref> | "
|
||
".tasks complete #<ref> | "
|
||
".tasks undo #<ref>"
|
||
)
|
||
return True
|
||
|
||
def _extract_totp_secret_candidate(self, command_text: str) -> str:
|
||
text = str(command_text or "").strip()
|
||
if not text:
|
||
return ""
|
||
lowered = text.lower()
|
||
if lowered.startswith("otpauth://"):
|
||
parsed = urlparse(text)
|
||
query = parse_qs(parsed.query or "")
|
||
return str((query.get("secret") or [""])[0] or "").strip()
|
||
if lowered.startswith(".totp"):
|
||
rest = text[len(".totp"):].strip()
|
||
if not rest:
|
||
return ""
|
||
parts = rest.split(maxsplit=1)
|
||
action = str(parts[0] or "").strip().lower()
|
||
if action in {"enroll", "set"} and len(parts) > 1:
|
||
return str(parts[1] or "").strip()
|
||
if action in {"status", "help"}:
|
||
return ""
|
||
return rest
|
||
compact = text.replace(" ", "").strip().upper()
|
||
if TOTP_BASE32_SECRET_RE.match(compact):
|
||
return compact
|
||
return ""
|
||
|
||
async def _handle_totp_command(self, user, body, sym):
|
||
command = str(body or "").strip()
|
||
lowered = command.lower()
|
||
if lowered.startswith(".totp status"):
|
||
exists = await sync_to_async(
|
||
lambda: __import__(
|
||
"django_otp.plugins.otp_totp.models",
|
||
fromlist=["TOTPDevice"],
|
||
)
|
||
.TOTPDevice.objects.filter(user=user, confirmed=True)
|
||
.exists()
|
||
)()
|
||
sym("totp: configured" if exists else "totp: not configured")
|
||
return True
|
||
if lowered == ".totp help":
|
||
sym("totp: .totp enroll <base32-secret|otpauth-uri> | .totp status")
|
||
return True
|
||
|
||
secret_candidate = self._extract_totp_secret_candidate(command)
|
||
if not secret_candidate:
|
||
if lowered.startswith(".totp"):
|
||
sym("usage: .totp enroll <base32-secret|otpauth-uri>")
|
||
return True
|
||
return False
|
||
|
||
normalized = str(secret_candidate).replace(" ", "").strip().upper()
|
||
try:
|
||
key_bytes = base64.b32decode(normalized, casefold=True)
|
||
except Exception:
|
||
sym("totp: invalid secret format")
|
||
return True
|
||
if len(key_bytes) < 10:
|
||
sym("totp: secret too short")
|
||
return True
|
||
|
||
def _save_device():
|
||
from django_otp.plugins.otp_totp.models import TOTPDevice
|
||
|
||
device = (
|
||
TOTPDevice.objects.filter(user=user)
|
||
.order_by("-id")
|
||
.first()
|
||
)
|
||
if device is None:
|
||
device = TOTPDevice(user=user, name="gateway")
|
||
device.key = key_bytes.hex()
|
||
device.confirmed = True
|
||
device.step = 30
|
||
device.t0 = 0
|
||
device.digits = 6
|
||
device.tolerance = 1
|
||
device.drift = 0
|
||
device.save()
|
||
return device.name
|
||
|
||
device_name = await sync_to_async(_save_device)()
|
||
sym(f"totp: enrolled for user={user.username} device={device_name}")
|
||
return True
|
||
|
||
async def _route_gateway_command(
|
||
self,
|
||
*,
|
||
sender_user,
|
||
body,
|
||
sender_jid,
|
||
recipient_jid,
|
||
local_message,
|
||
message_meta,
|
||
sym,
|
||
):
|
||
command_text = str(body or "").strip()
|
||
|
||
async def _contacts_handler(_ctx, emit):
|
||
persons = await sync_to_async(list)(Person.objects.filter(user=sender_user).order_by("name"))
|
||
if not persons:
|
||
emit("No contacts found.")
|
||
return True
|
||
emit("Contacts: " + ", ".join([p.name for p in persons]))
|
||
return True
|
||
|
||
async def _help_handler(_ctx, emit):
|
||
for line in self._gateway_help_lines():
|
||
emit(line)
|
||
return True
|
||
|
||
async def _whoami_handler(_ctx, emit):
|
||
emit(str(sender_user.__dict__))
|
||
return True
|
||
|
||
async def _approval_handler(_ctx, emit):
|
||
return await self._handle_approval_command(sender_user, command_text, sender_jid, emit)
|
||
|
||
async def _tasks_handler(_ctx, emit):
|
||
return await self._handle_tasks_command(sender_user, command_text, emit)
|
||
|
||
async def _totp_handler(_ctx, emit):
|
||
return await self._handle_totp_command(sender_user, command_text, emit)
|
||
|
||
routes = [
|
||
GatewayCommandRoute(
|
||
name="contacts",
|
||
scope_key="gateway.contacts",
|
||
matcher=lambda text: str(text or "").strip().lower() == ".contacts",
|
||
handler=_contacts_handler,
|
||
),
|
||
GatewayCommandRoute(
|
||
name="help",
|
||
scope_key="gateway.help",
|
||
matcher=lambda text: str(text or "").strip().lower() == ".help",
|
||
handler=_help_handler,
|
||
),
|
||
GatewayCommandRoute(
|
||
name="whoami",
|
||
scope_key="gateway.whoami",
|
||
matcher=lambda text: str(text or "").strip().lower() == ".whoami",
|
||
handler=_whoami_handler,
|
||
),
|
||
GatewayCommandRoute(
|
||
name="approval",
|
||
scope_key="gateway.approval",
|
||
matcher=lambda text: str(text or "").strip().lower().startswith(".approval")
|
||
or any(
|
||
str(text or "").strip().lower().startswith(prefix + " ")
|
||
or str(text or "").strip().lower() == prefix
|
||
for prefix in self._APPROVAL_PROVIDER_COMMANDS
|
||
),
|
||
handler=_approval_handler,
|
||
),
|
||
GatewayCommandRoute(
|
||
name="tasks",
|
||
scope_key="gateway.tasks",
|
||
matcher=lambda text: str(text or "").strip().lower().startswith(".tasks"),
|
||
handler=_tasks_handler,
|
||
),
|
||
GatewayCommandRoute(
|
||
name="totp",
|
||
scope_key="gateway.totp",
|
||
matcher=lambda text: bool(self._extract_totp_secret_candidate(text)),
|
||
handler=_totp_handler,
|
||
),
|
||
]
|
||
handled = await dispatch_gateway_command(
|
||
context=GatewayCommandContext(
|
||
user=sender_user,
|
||
source_message=local_message,
|
||
service="xmpp",
|
||
channel_identifier=str(sender_jid or ""),
|
||
sender_identifier=str(sender_jid or ""),
|
||
message_text=command_text,
|
||
message_meta=dict(message_meta or {}),
|
||
payload={
|
||
"sender_jid": str(sender_jid or ""),
|
||
"recipient_jid": str(recipient_jid or ""),
|
||
},
|
||
),
|
||
routes=routes,
|
||
emit=sym,
|
||
)
|
||
if not handled and command_text.startswith("."):
|
||
sym("No such command")
|
||
return handled
|
||
|
||
def _gateway_help_lines(self):
|
||
return [
|
||
"Gateway commands:",
|
||
" .contacts — list contacts",
|
||
" .whoami — show current user",
|
||
" .help — show this help",
|
||
" .totp enroll <secret|otpauth-uri> — enroll TOTP for this user",
|
||
" .totp status — show whether TOTP is configured",
|
||
"Approval commands:",
|
||
" .approval list-pending [all] — list pending approval requests",
|
||
" .approval approve <key> — approve a request",
|
||
" .approval reject <key> — reject a request",
|
||
" .approval status <key> — check request status",
|
||
"Task commands:",
|
||
" .tasks list [status] [limit] — list tasks",
|
||
" .tasks show #<ref> — show task details",
|
||
" .tasks complete #<ref> — mark task complete",
|
||
" .tasks undo #<ref> — remove task",
|
||
]
|
||
|
||
async def _handle_mitigation_command(self, sender_user, body, sym):
|
||
def parse_parts(raw):
|
||
return [part.strip() for part in raw.split("|")]
|
||
|
||
command = body.strip()
|
||
if command == ".mitigation help":
|
||
sym(
|
||
"Mitigation commands: "
|
||
".mitigation list | "
|
||
".mitigation show <person> | "
|
||
".mitigation rule-add <person>|<title>|<content> | "
|
||
".mitigation rule-del <person>|<title> | "
|
||
".mitigation game-add <person>|<title>|<instructions> | "
|
||
".mitigation game-del <person>|<title> | "
|
||
".mitigation correction-add <person>|<title>|<clarification> | "
|
||
".mitigation correction-del <person>|<title> | "
|
||
".mitigation fundamentals-set <person>|<item1;item2;...> | "
|
||
".mitigation plan-set <person>|<draft|active|archived>|<auto|guided> | "
|
||
".mitigation auto <person>|on|off | "
|
||
".mitigation auto-status <person>"
|
||
)
|
||
return True
|
||
|
||
if command == ".mitigation list":
|
||
plans = await sync_to_async(list)(
|
||
PatternMitigationPlan.objects.filter(user=sender_user)
|
||
.select_related("conversation")
|
||
.order_by("-updated_at")[:15]
|
||
)
|
||
if not plans:
|
||
sym("No mitigation plans found.")
|
||
return True
|
||
rows = []
|
||
for plan in plans:
|
||
person_name = (
|
||
plan.conversation.participants.order_by("name").first().name
|
||
if plan.conversation.participants.exists()
|
||
else "Unknown"
|
||
)
|
||
rows.append(f"{person_name}: {plan.title}")
|
||
sym("Plans: " + " | ".join(rows))
|
||
return True
|
||
|
||
if command.startswith(".mitigation show "):
|
||
person_name = command.replace(".mitigation show ", "", 1).strip().title()
|
||
person = await sync_to_async(
|
||
lambda: Person.objects.filter(
|
||
user=sender_user, name__iexact=person_name
|
||
).first()
|
||
)()
|
||
if not person:
|
||
sym("Unknown person.")
|
||
return True
|
||
plan = await sync_to_async(self._get_or_create_plan)(sender_user, person)
|
||
rule_count = await sync_to_async(plan.rules.count)()
|
||
game_count = await sync_to_async(plan.games.count)()
|
||
sym(f"{person.name}: {plan.title} | rules={rule_count} games={game_count}")
|
||
return True
|
||
|
||
if command.startswith(".mitigation rule-add "):
|
||
payload = command.replace(".mitigation rule-add ", "", 1)
|
||
parts = parse_parts(payload)
|
||
if len(parts) < 3:
|
||
sym("Usage: .mitigation rule-add <person>|<title>|<content>")
|
||
return True
|
||
person_name, title, content = (
|
||
parts[0].title(),
|
||
parts[1],
|
||
"|".join(parts[2:]),
|
||
)
|
||
person = await sync_to_async(
|
||
lambda: Person.objects.filter(
|
||
user=sender_user, name__iexact=person_name
|
||
).first()
|
||
)()
|
||
if not person:
|
||
sym("Unknown person.")
|
||
return True
|
||
plan = await sync_to_async(self._get_or_create_plan)(sender_user, person)
|
||
await sync_to_async(PatternMitigationRule.objects.create)(
|
||
user=sender_user,
|
||
plan=plan,
|
||
title=title[:255],
|
||
content=content,
|
||
enabled=True,
|
||
)
|
||
sym("Rule added.")
|
||
return True
|
||
|
||
if command.startswith(".mitigation rule-del "):
|
||
payload = command.replace(".mitigation rule-del ", "", 1)
|
||
parts = parse_parts(payload)
|
||
if len(parts) < 2:
|
||
sym("Usage: .mitigation rule-del <person>|<title>")
|
||
return True
|
||
person_name, title = parts[0].title(), "|".join(parts[1:])
|
||
person = await sync_to_async(
|
||
lambda: Person.objects.filter(
|
||
user=sender_user, name__iexact=person_name
|
||
).first()
|
||
)()
|
||
if not person:
|
||
sym("Unknown person.")
|
||
return True
|
||
plan = await sync_to_async(self._get_or_create_plan)(sender_user, person)
|
||
deleted, _ = await sync_to_async(
|
||
lambda: PatternMitigationRule.objects.filter(
|
||
user=sender_user,
|
||
plan=plan,
|
||
title__iexact=title,
|
||
).delete()
|
||
)()
|
||
sym("Rule deleted." if deleted else "Rule not found.")
|
||
return True
|
||
|
||
if command.startswith(".mitigation game-add "):
|
||
payload = command.replace(".mitigation game-add ", "", 1)
|
||
parts = parse_parts(payload)
|
||
if len(parts) < 3:
|
||
sym("Usage: .mitigation game-add <person>|<title>|<instructions>")
|
||
return True
|
||
person_name, title, content = (
|
||
parts[0].title(),
|
||
parts[1],
|
||
"|".join(parts[2:]),
|
||
)
|
||
person = await sync_to_async(
|
||
lambda: Person.objects.filter(
|
||
user=sender_user, name__iexact=person_name
|
||
).first()
|
||
)()
|
||
if not person:
|
||
sym("Unknown person.")
|
||
return True
|
||
plan = await sync_to_async(self._get_or_create_plan)(sender_user, person)
|
||
await sync_to_async(PatternMitigationGame.objects.create)(
|
||
user=sender_user,
|
||
plan=plan,
|
||
title=title[:255],
|
||
instructions=content,
|
||
enabled=True,
|
||
)
|
||
sym("Game added.")
|
||
return True
|
||
|
||
if command.startswith(".mitigation game-del "):
|
||
payload = command.replace(".mitigation game-del ", "", 1)
|
||
parts = parse_parts(payload)
|
||
if len(parts) < 2:
|
||
sym("Usage: .mitigation game-del <person>|<title>")
|
||
return True
|
||
person_name, title = parts[0].title(), "|".join(parts[1:])
|
||
person = await sync_to_async(
|
||
lambda: Person.objects.filter(
|
||
user=sender_user, name__iexact=person_name
|
||
).first()
|
||
)()
|
||
if not person:
|
||
sym("Unknown person.")
|
||
return True
|
||
plan = await sync_to_async(self._get_or_create_plan)(sender_user, person)
|
||
deleted, _ = await sync_to_async(
|
||
lambda: PatternMitigationGame.objects.filter(
|
||
user=sender_user,
|
||
plan=plan,
|
||
title__iexact=title,
|
||
).delete()
|
||
)()
|
||
sym("Game deleted." if deleted else "Game not found.")
|
||
return True
|
||
|
||
if command.startswith(".mitigation correction-add "):
|
||
payload = command.replace(".mitigation correction-add ", "", 1)
|
||
parts = parse_parts(payload)
|
||
if len(parts) < 3:
|
||
sym(
|
||
"Usage: .mitigation correction-add <person>|<title>|<clarification>"
|
||
)
|
||
return True
|
||
person_name, title, clarification = (
|
||
parts[0].title(),
|
||
parts[1],
|
||
"|".join(parts[2:]),
|
||
)
|
||
person = await sync_to_async(
|
||
lambda: Person.objects.filter(
|
||
user=sender_user, name__iexact=person_name
|
||
).first()
|
||
)()
|
||
if not person:
|
||
sym("Unknown person.")
|
||
return True
|
||
plan = await sync_to_async(self._get_or_create_plan)(sender_user, person)
|
||
await sync_to_async(PatternMitigationCorrection.objects.create)(
|
||
user=sender_user,
|
||
plan=plan,
|
||
title=title[:255],
|
||
clarification=clarification,
|
||
source_phrase="",
|
||
perspective="second_person",
|
||
share_target="both",
|
||
language_style="adapted",
|
||
enabled=True,
|
||
)
|
||
sym("Correction added.")
|
||
return True
|
||
|
||
if command.startswith(".mitigation correction-del "):
|
||
payload = command.replace(".mitigation correction-del ", "", 1)
|
||
parts = parse_parts(payload)
|
||
if len(parts) < 2:
|
||
sym("Usage: .mitigation correction-del <person>|<title>")
|
||
return True
|
||
person_name, title = parts[0].title(), "|".join(parts[1:])
|
||
person = await sync_to_async(
|
||
lambda: Person.objects.filter(
|
||
user=sender_user, name__iexact=person_name
|
||
).first()
|
||
)()
|
||
if not person:
|
||
sym("Unknown person.")
|
||
return True
|
||
plan = await sync_to_async(self._get_or_create_plan)(sender_user, person)
|
||
deleted, _ = await sync_to_async(
|
||
lambda: PatternMitigationCorrection.objects.filter(
|
||
user=sender_user,
|
||
plan=plan,
|
||
title__iexact=title,
|
||
).delete()
|
||
)()
|
||
sym("Correction deleted." if deleted else "Correction not found.")
|
||
return True
|
||
|
||
if command.startswith(".mitigation fundamentals-set "):
|
||
payload = command.replace(".mitigation fundamentals-set ", "", 1)
|
||
parts = parse_parts(payload)
|
||
if len(parts) < 2:
|
||
sym("Usage: .mitigation fundamentals-set <person>|<item1;item2;...>")
|
||
return True
|
||
person_name, values = parts[0].title(), "|".join(parts[1:])
|
||
person = await sync_to_async(
|
||
lambda: Person.objects.filter(
|
||
user=sender_user, name__iexact=person_name
|
||
).first()
|
||
)()
|
||
if not person:
|
||
sym("Unknown person.")
|
||
return True
|
||
plan = await sync_to_async(self._get_or_create_plan)(sender_user, person)
|
||
items = [item.strip() for item in values.split(";") if item.strip()]
|
||
plan.fundamental_items = items
|
||
await sync_to_async(plan.save)(
|
||
update_fields=["fundamental_items", "updated_at"]
|
||
)
|
||
sym(f"Fundamentals updated ({len(items)}).")
|
||
return True
|
||
|
||
if command.startswith(".mitigation plan-set "):
|
||
payload = command.replace(".mitigation plan-set ", "", 1)
|
||
parts = parse_parts(payload)
|
||
if len(parts) < 3:
|
||
sym(
|
||
"Usage: .mitigation plan-set <person>|<draft|active|archived>|<auto|guided>"
|
||
)
|
||
return True
|
||
person_name, status_value, mode_value = (
|
||
parts[0].title(),
|
||
parts[1].lower(),
|
||
parts[2].lower(),
|
||
)
|
||
person = await sync_to_async(
|
||
lambda: Person.objects.filter(
|
||
user=sender_user, name__iexact=person_name
|
||
).first()
|
||
)()
|
||
if not person:
|
||
sym("Unknown person.")
|
||
return True
|
||
plan = await sync_to_async(self._get_or_create_plan)(sender_user, person)
|
||
valid_status = {key for key, _ in PatternMitigationPlan.STATUS_CHOICES}
|
||
valid_modes = {
|
||
key for key, _ in PatternMitigationPlan.CREATION_MODE_CHOICES
|
||
}
|
||
if status_value in valid_status:
|
||
plan.status = status_value
|
||
if mode_value in valid_modes:
|
||
plan.creation_mode = mode_value
|
||
await sync_to_async(plan.save)(
|
||
update_fields=["status", "creation_mode", "updated_at"]
|
||
)
|
||
sym(f"Plan updated: status={plan.status}, mode={plan.creation_mode}")
|
||
return True
|
||
|
||
if command.startswith(".mitigation auto "):
|
||
payload = command.replace(".mitigation auto ", "", 1)
|
||
parts = parse_parts(payload)
|
||
if len(parts) < 2:
|
||
sym("Usage: .mitigation auto <person>|on|off")
|
||
return True
|
||
person_name, state = parts[0].title(), parts[1].lower()
|
||
person = await sync_to_async(
|
||
lambda: Person.objects.filter(
|
||
user=sender_user, name__iexact=person_name
|
||
).first()
|
||
)()
|
||
if not person:
|
||
sym("Unknown person.")
|
||
return True
|
||
conversation = await sync_to_async(self._get_workspace_conversation)(
|
||
sender_user, person
|
||
)
|
||
auto_obj, _ = await sync_to_async(
|
||
PatternMitigationAutoSettings.objects.get_or_create
|
||
)(
|
||
user=sender_user,
|
||
conversation=conversation,
|
||
)
|
||
auto_obj.enabled = state in {"on", "true", "1", "yes"}
|
||
await sync_to_async(auto_obj.save)(update_fields=["enabled", "updated_at"])
|
||
sym(
|
||
f"Automation {'enabled' if auto_obj.enabled else 'disabled'} for {person.name}."
|
||
)
|
||
return True
|
||
|
||
if command.startswith(".mitigation auto-status "):
|
||
person_name = (
|
||
command.replace(".mitigation auto-status ", "", 1).strip().title()
|
||
)
|
||
person = await sync_to_async(
|
||
lambda: Person.objects.filter(
|
||
user=sender_user, name__iexact=person_name
|
||
).first()
|
||
)()
|
||
if not person:
|
||
sym("Unknown person.")
|
||
return True
|
||
conversation = await sync_to_async(self._get_workspace_conversation)(
|
||
sender_user, person
|
||
)
|
||
auto_obj, _ = await sync_to_async(
|
||
PatternMitigationAutoSettings.objects.get_or_create
|
||
)(
|
||
user=sender_user,
|
||
conversation=conversation,
|
||
)
|
||
sym(
|
||
f"{person.name}: auto={'on' if auto_obj.enabled else 'off'}, "
|
||
f"pattern={'on' if auto_obj.auto_pattern_recognition else 'off'}, "
|
||
f"corrections={'on' if auto_obj.auto_create_corrections else 'off'}"
|
||
)
|
||
return True
|
||
|
||
return False
|
||
|
||
def update_roster(self, jid, name=None):
|
||
"""
|
||
Adds or updates a user in the roster.
|
||
"""
|
||
iq = self.Iq()
|
||
iq["type"] = "set"
|
||
iq["roster"]["items"] = {jid: {"name": name or jid}}
|
||
|
||
iq.send()
|
||
self.log.debug("Updated roster: added %s (%s)", jid, name)
|
||
|
||
def on_chatstate_active(self, msg):
|
||
"""
|
||
Handle when a user is actively engaged in the chat.
|
||
"""
|
||
self.log.debug("Chat state active from %s", msg["from"])
|
||
|
||
self.get_identifier(msg)
|
||
|
||
def on_chatstate_composing(self, msg):
|
||
"""
|
||
Handle when a user is typing a message.
|
||
"""
|
||
self.log.debug("Chat state composing from %s", msg["from"])
|
||
|
||
identifier = self.get_identifier(msg)
|
||
if identifier:
|
||
asyncio.create_task(
|
||
self.ur.started_typing(
|
||
"xmpp",
|
||
identifier=identifier,
|
||
)
|
||
)
|
||
|
||
def on_chatstate_paused(self, msg):
|
||
"""
|
||
Handle when a user has paused typing.
|
||
"""
|
||
self.log.debug("Chat state paused from %s", msg["from"])
|
||
|
||
identifier = self.get_identifier(msg)
|
||
if identifier:
|
||
asyncio.create_task(
|
||
self.ur.stopped_typing(
|
||
"xmpp",
|
||
identifier=identifier,
|
||
)
|
||
)
|
||
|
||
def on_chatstate_inactive(self, msg):
|
||
"""
|
||
Handle when a user is inactive in the chat.
|
||
"""
|
||
self.log.debug("Chat state inactive from %s", msg["from"])
|
||
|
||
self.get_identifier(msg)
|
||
|
||
def on_chatstate_gone(self, msg):
|
||
"""
|
||
Handle when a user has left the chat.
|
||
"""
|
||
self.log.debug("Chat state gone from %s", msg["from"])
|
||
|
||
self.get_identifier(msg)
|
||
|
||
def on_presence_available(self, pres):
|
||
"""
|
||
Handle when a user becomes available.
|
||
"""
|
||
self.log.debug("Presence available from %s", pres["from"])
|
||
|
||
def on_presence_dnd(self, pres):
|
||
"""
|
||
Handle when a user sets 'Do Not Disturb' status.
|
||
"""
|
||
self.log.debug("Presence dnd from %s", pres["from"])
|
||
|
||
def on_presence_xa(self, pres):
|
||
"""
|
||
Handle when a user sets 'Extended Away' status.
|
||
"""
|
||
self.log.debug("Presence extended-away from %s", pres["from"])
|
||
|
||
def on_presence_chat(self, pres):
|
||
"""
|
||
Handle when a user is actively available for chat.
|
||
"""
|
||
self.log.debug("Presence chat-available from %s", pres["from"])
|
||
|
||
def on_presence_away(self, pres):
|
||
"""
|
||
Handle when a user sets 'Away' status.
|
||
"""
|
||
self.log.debug("Presence away from %s", pres["from"])
|
||
|
||
def on_presence_unavailable(self, pres):
|
||
"""
|
||
Handle when a user goes offline or unavailable.
|
||
"""
|
||
self.log.debug("Presence unavailable from %s", pres["from"])
|
||
|
||
def on_presence_subscribe(self, pres):
|
||
"""
|
||
Handle incoming presence subscription requests.
|
||
Accept only if the recipient has a contact matching the sender.
|
||
"""
|
||
|
||
sender_jid = str(pres["from"]).split("/")[0] # Bare JID (user@domain)
|
||
recipient_jid = str(pres["to"]).split("/")[0]
|
||
|
||
self.log.debug(
|
||
f"Received subscription request from {sender_jid} to {recipient_jid}"
|
||
)
|
||
|
||
try:
|
||
# Extract sender and recipient usernames
|
||
user_username, _ = sender_jid.split("@", 1)
|
||
recipient_username, _ = recipient_jid.split("@", 1)
|
||
|
||
# Parse recipient_name and recipient_service (e.g., "mark|signal")
|
||
if "|" in recipient_username:
|
||
person_name, service = recipient_username.split("|")
|
||
person_name = person_name.title() # Capitalize for consistency
|
||
else:
|
||
person_name = recipient_username.title()
|
||
service = None
|
||
|
||
# Lookup user in Django
|
||
self.log.debug("Resolving subscription user=%s", user_username)
|
||
user = User.objects.get(username=user_username)
|
||
|
||
# Find Person object with name=person_name.lower()
|
||
self.log.debug("Resolving subscription person=%s", person_name.title())
|
||
person = Person.objects.get(user=user, name=person_name.title())
|
||
|
||
# Ensure a PersonIdentifier exists for this user, person, and service
|
||
self.log.debug("Resolving subscription identifier service=%s", service)
|
||
PersonIdentifier.objects.get(user=user, person=person, service=service)
|
||
|
||
component_jid = f"{person_name.lower()}|{service}@{self.boundjid.bare}"
|
||
|
||
# Accept the subscription
|
||
self.send_presence(ptype="subscribed", pto=sender_jid, pfrom=component_jid)
|
||
self.log.debug(
|
||
f"Accepted subscription from {sender_jid}, sent from {component_jid}"
|
||
)
|
||
|
||
# Send a presence request **from the recipient to the sender** (ASKS THEM TO ACCEPT BACK)
|
||
# self.send_presence(ptype="subscribe", pto=sender_jid, pfrom=component_jid)
|
||
|
||
# Add sender to roster
|
||
# self.update_roster(sender_jid, name=sender_jid.split("@")[0])
|
||
|
||
# Send presence update to sender **from the correct JID**
|
||
self.send_presence(ptype="available", pto=sender_jid, pfrom=component_jid)
|
||
self.log.debug(
|
||
"Sent presence update from %s to %s", component_jid, sender_jid
|
||
)
|
||
|
||
except (User.DoesNotExist, Person.DoesNotExist, PersonIdentifier.DoesNotExist):
|
||
# If any lookup fails, reject the subscription
|
||
self.log.warning(
|
||
f"Subscription request from {sender_jid} rejected (recipient does not have this contact)."
|
||
)
|
||
self.send_presence(ptype="unsubscribed", pto=sender_jid)
|
||
|
||
except ValueError:
|
||
return
|
||
|
||
def on_presence_subscribed(self, pres):
|
||
"""
|
||
Handle successful subscription confirmations.
|
||
"""
|
||
self.log.debug("Subscription to %s accepted", pres["from"])
|
||
|
||
def on_presence_unsubscribe(self, pres):
|
||
"""
|
||
Handle when a user unsubscribes from presence updates.
|
||
"""
|
||
self.log.debug("Presence unsubscribe from %s", pres["from"])
|
||
|
||
def on_presence_unsubscribed(self, pres):
|
||
"""
|
||
Handle when a user's unsubscription request is confirmed.
|
||
"""
|
||
self.log.debug("Presence unsubscribed confirmation from %s", pres["from"])
|
||
|
||
def on_roster_subscription_request(self, pres):
|
||
"""
|
||
Handle roster subscription requests.
|
||
"""
|
||
self.log.debug("Roster subscription request from %s", pres["from"])
|
||
|
||
async def session_start(self, *args):
|
||
self.log.info("XMPP session started")
|
||
self._session_live = True
|
||
self._connect_inflight = False
|
||
self._reconnect_delay_seconds = 1.0
|
||
if self._reconnect_task and not self._reconnect_task.done():
|
||
self._reconnect_task.cancel()
|
||
self._reconnect_task = None
|
||
# This client connects as an external component, not a user client;
|
||
# XEP-0280 (carbons) is client-scoped and not valid here.
|
||
self.log.debug("Skipping carbons enable for component session")
|
||
await self._bootstrap_omemo_for_authentic_channel()
|
||
|
||
async def _reconnect_loop(self):
|
||
try:
|
||
while True:
|
||
delay = float(self._reconnect_delay_seconds)
|
||
await asyncio.sleep(delay)
|
||
if self._session_live or self._connect_inflight:
|
||
return
|
||
try:
|
||
self.log.info("XMPP reconnect attempt delay_s=%.1f", delay)
|
||
self._connect_inflight = True
|
||
connected = self.connect()
|
||
if connected is False:
|
||
raise RuntimeError("connect returned false")
|
||
return
|
||
except Exception as exc:
|
||
self.log.warning("XMPP reconnect attempt failed: %s", exc)
|
||
self._connect_inflight = False
|
||
self._reconnect_delay_seconds = min(
|
||
self._reconnect_delay_max_seconds,
|
||
max(1.0, float(self._reconnect_delay_seconds) * 2.0),
|
||
)
|
||
except asyncio.CancelledError:
|
||
return
|
||
finally:
|
||
if not self._session_live:
|
||
self._connect_inflight = False
|
||
self._reconnect_task = None
|
||
|
||
def _schedule_reconnect(self):
|
||
if self._reconnect_task and not self._reconnect_task.done():
|
||
return
|
||
self._reconnect_task = self.loop.create_task(self._reconnect_loop())
|
||
|
||
def on_disconnected(self, *args):
|
||
"""
|
||
Handles XMPP disconnection and triggers a reconnect loop.
|
||
"""
|
||
self._session_live = False
|
||
self._connect_inflight = False
|
||
self.log.warning(
|
||
"XMPP disconnected, scheduling reconnect attempt in %.1fs",
|
||
float(self._reconnect_delay_seconds),
|
||
)
|
||
self._schedule_reconnect()
|
||
|
||
async def request_upload_slot(self, recipient, filename, content_type, size):
|
||
"""
|
||
Requests an upload slot from XMPP for HTTP File Upload (XEP-0363).
|
||
|
||
Args:
|
||
recipient (str): The JID of the recipient.
|
||
filename (str): The filename for the upload.
|
||
content_type (str): The file's MIME type.
|
||
size (int): The file size in bytes.
|
||
|
||
Returns:
|
||
tuple | None: (upload_url, put_url, auth_header) or None if failed.
|
||
"""
|
||
# upload_service = await self['xep_0363'].find_upload_service()
|
||
# if not upload_service:
|
||
# self.log.error("No XEP-0363 upload service found.")
|
||
# return None
|
||
|
||
upload_service_jid = str(
|
||
getattr(settings, "XMPP_UPLOAD_SERVICE", "")
|
||
or getattr(settings, "XMPP_UPLOAD_JID", "")
|
||
).strip()
|
||
if not upload_service_jid:
|
||
discovered = None
|
||
try:
|
||
discovered = await self["xep_0363"].find_upload_service()
|
||
except Exception as exc:
|
||
self.log.debug("XMPP upload service discovery failed: %s", exc)
|
||
if discovered:
|
||
discovered_jid = ""
|
||
try:
|
||
discovered_jid = str(getattr(discovered, "jid", "") or "").strip()
|
||
except Exception:
|
||
discovered_jid = ""
|
||
|
||
if not discovered_jid:
|
||
raw_discovered = str(discovered or "").strip()
|
||
if raw_discovered.startswith("<"):
|
||
try:
|
||
node = ET.fromstring(raw_discovered)
|
||
discovered_jid = str(node.attrib.get("from") or "").strip()
|
||
except Exception:
|
||
discovered_jid = ""
|
||
else:
|
||
discovered_jid = raw_discovered
|
||
|
||
upload_service_jid = discovered_jid
|
||
if upload_service_jid:
|
||
self.log.info(
|
||
"Discovered XMPP upload service via XEP-0363: %s",
|
||
upload_service_jid,
|
||
)
|
||
else:
|
||
if not self._upload_config_warned:
|
||
self.log.warning(
|
||
"XMPP upload service not configured/discoverable; skipping attachment upload. "
|
||
"Set XMPP_UPLOAD_SERVICE (or XMPP_UPLOAD_JID)."
|
||
)
|
||
self._upload_config_warned = True
|
||
return None
|
||
|
||
try:
|
||
slot = await self["xep_0363"].request_slot(
|
||
jid=upload_service_jid,
|
||
filename=filename,
|
||
content_type=content_type,
|
||
size=size,
|
||
)
|
||
|
||
if slot is None:
|
||
self.log.error(f"Failed to obtain upload slot for {filename}")
|
||
return None
|
||
|
||
# Parse the XML response
|
||
root = ET.fromstring(str(slot)) # Convert to string if necessary
|
||
namespace = "{urn:xmpp:http:upload:0}" # Define the namespace
|
||
|
||
get_url = root.find(f".//{namespace}get").attrib.get("url")
|
||
put_element = root.find(f".//{namespace}put")
|
||
put_url = put_element.attrib.get("url")
|
||
|
||
# Extract the Authorization header correctly
|
||
header_element = put_element.find(
|
||
f"./{namespace}header[@name='Authorization']"
|
||
)
|
||
auth_header = (
|
||
header_element.text.strip() if header_element is not None else None
|
||
)
|
||
|
||
if not get_url or not put_url:
|
||
self.log.error(f"Missing URLs in upload slot: {slot}")
|
||
return None
|
||
|
||
return get_url, put_url, auth_header
|
||
|
||
except Exception as e:
|
||
self.log.error(f"Exception while requesting upload slot: {e}")
|
||
return None
|
||
|
||
async def message(self, msg):
|
||
"""
|
||
Process incoming XMPP messages.
|
||
"""
|
||
|
||
def sym(value):
|
||
msg.reply(f"[>] {value}").send()
|
||
|
||
xmpp_message_id = str(msg.get("id") or "").strip()
|
||
|
||
# Extract sender JID (full format: user@domain/resource)
|
||
sender_jid = str(msg["from"])
|
||
|
||
# Split into username@domain and optional resource
|
||
sender_parts = sender_jid.split("/", 1)
|
||
sender_bare_jid = sender_parts[0] # Always present: user@domain
|
||
sender_username, sender_domain = sender_bare_jid.split("@", 1)
|
||
|
||
sender_resource = (
|
||
sender_parts[1] if len(sender_parts) > 1 else None
|
||
) # Extract resource if present
|
||
|
||
# Extract recipient JID (should match component JID format)
|
||
recipient_jid = str(msg["to"])
|
||
|
||
if "@" in recipient_jid:
|
||
recipient_username, recipient_domain = recipient_jid.split("@", 1)
|
||
else:
|
||
recipient_username = recipient_jid
|
||
recipient_domain = recipient_jid
|
||
|
||
# Attempt to decrypt OMEMO-encrypted messages before body extraction.
|
||
original_msg = msg
|
||
omemo_plugin = self._get_omemo_plugin()
|
||
if omemo_plugin:
|
||
try:
|
||
if omemo_plugin.is_encrypted(msg):
|
||
decrypted, _ = await omemo_plugin.decrypt_message(msg)
|
||
msg = decrypted
|
||
self.log.debug("OMEMO: decrypted message from %s", sender_jid)
|
||
except Exception as exc:
|
||
self.log.warning("OMEMO: decryption failed from %s: %s", sender_jid, exc)
|
||
|
||
# Extract message body
|
||
body = msg["body"] if msg["body"] else ""
|
||
parsed_reaction = _extract_xmpp_reaction(msg)
|
||
parsed_reply_target = _extract_xmpp_reply_target_id(msg)
|
||
greentext_reaction = _parse_greentext_reaction(body)
|
||
|
||
attachments = []
|
||
self.log.debug(
|
||
"Received XMPP stanza: %s", ET.tostring(msg.xml, encoding="unicode")
|
||
)
|
||
|
||
# Extract attachments from standard XMPP payloads.
|
||
for att in msg.xml.findall(".//{urn:xmpp:attachments}attachment"):
|
||
url_value = _clean_url(att.attrib.get("url"))
|
||
if not url_value:
|
||
continue
|
||
try:
|
||
safe_url = validate_attachment_url(url_value)
|
||
filename, content_type = validate_attachment_metadata(
|
||
filename=att.attrib.get("filename") or _filename_from_url(safe_url),
|
||
content_type=att.attrib.get("content_type")
|
||
or "application/octet-stream",
|
||
)
|
||
except Exception as exc:
|
||
self.log.warning("xmpp dropped unsafe attachment url=%s: %s", url_value, exc)
|
||
continue
|
||
attachments.append(
|
||
{
|
||
"url": safe_url,
|
||
"filename": filename,
|
||
"content_type": content_type,
|
||
}
|
||
)
|
||
|
||
# Extract attachments from XEP-0066 OOB payloads.
|
||
for oob in msg.xml.findall(".//{jabber:x:oob}x/{jabber:x:oob}url"):
|
||
url_value = _clean_url(oob.text)
|
||
if not url_value:
|
||
continue
|
||
try:
|
||
safe_url = validate_attachment_url(url_value)
|
||
filename, guessed_content_type = validate_attachment_metadata(
|
||
filename=_filename_from_url(safe_url),
|
||
content_type=_content_type_from_filename_or_url(safe_url),
|
||
)
|
||
except Exception as exc:
|
||
self.log.warning("xmpp dropped unsafe oob url=%s: %s", url_value, exc)
|
||
continue
|
||
attachments.append(
|
||
{
|
||
"url": safe_url,
|
||
"filename": filename,
|
||
"content_type": guessed_content_type,
|
||
}
|
||
)
|
||
|
||
# Fallback extraction for alternate attachment encodings.
|
||
extracted_urls = _extract_xml_attachment_urls(msg)
|
||
existing_urls = {str(item.get("url") or "").strip() for item in attachments}
|
||
for url_value in extracted_urls:
|
||
if url_value in existing_urls:
|
||
continue
|
||
try:
|
||
safe_url = validate_attachment_url(url_value)
|
||
filename, guessed_content_type = validate_attachment_metadata(
|
||
filename=_filename_from_url(safe_url),
|
||
content_type=_content_type_from_filename_or_url(safe_url),
|
||
)
|
||
except Exception as exc:
|
||
self.log.warning("xmpp dropped extracted unsafe url=%s: %s", url_value, exc)
|
||
continue
|
||
attachments.append(
|
||
{
|
||
"url": safe_url,
|
||
"filename": filename,
|
||
"content_type": guessed_content_type,
|
||
}
|
||
)
|
||
|
||
if (
|
||
not body or body.strip().lower() in {"[no body]", "(no text)"}
|
||
) and attachments:
|
||
attachment_urls = [
|
||
str(item.get("url") or "").strip()
|
||
for item in attachments
|
||
if str(item.get("url") or "").strip()
|
||
]
|
||
if attachment_urls:
|
||
body = "\n".join(attachment_urls)
|
||
|
||
relay_body = body
|
||
attachment_urls_for_body = [
|
||
str(item.get("url") or "").strip()
|
||
for item in attachments
|
||
if str(item.get("url") or "").strip()
|
||
]
|
||
if attachment_urls_for_body:
|
||
joined_urls = "\n".join(attachment_urls_for_body).strip()
|
||
if str(relay_body or "").strip() == joined_urls:
|
||
relay_body = ""
|
||
|
||
self.log.debug("Extracted %s attachments from XMPP message", len(attachments))
|
||
# Log extracted information with variable name annotations
|
||
log_message = (
|
||
f"Sender JID: {sender_jid}, Sender Username: {sender_username}, Sender Domain: {sender_domain}, "
|
||
f"Sender Resource: {sender_resource if sender_resource else '[No Resource]'}, "
|
||
f"Recipient JID: {recipient_jid}, Recipient Username: {recipient_username}, Recipient Domain: {recipient_domain}, "
|
||
f"Body: {body or '[No Body]'}"
|
||
)
|
||
self.log.debug(log_message)
|
||
|
||
# Ensure recipient domain matches our configured component
|
||
expected_domain = settings.XMPP_JID # 'jews.zm.is' in your config
|
||
if recipient_domain != expected_domain:
|
||
self.log.warning(
|
||
f"Invalid recipient domain: {recipient_domain}, expected {expected_domain}"
|
||
)
|
||
return
|
||
|
||
# Lookup sender in Django's User model
|
||
try:
|
||
sender_user = User.objects.get(username=sender_username)
|
||
except User.DoesNotExist:
|
||
self.log.warning(f"Unknown sender: {sender_username}")
|
||
return
|
||
|
||
# Record the sender's OMEMO state (uses the original, pre-decryption stanza).
|
||
try:
|
||
await self._record_sender_omemo_state(
|
||
sender_user,
|
||
sender_jid=sender_jid,
|
||
recipient_jid=recipient_jid,
|
||
message_stanza=original_msg,
|
||
)
|
||
except Exception as exc:
|
||
self.log.warning("OMEMO: failed to record sender state: %s", exc)
|
||
omemo_observation = _extract_sender_omemo_client_key(original_msg)
|
||
|
||
# Enforce mandatory encryption policy.
|
||
try:
|
||
from core.models import UserXmppSecuritySettings
|
||
sec_settings = await sync_to_async(
|
||
lambda: UserXmppSecuritySettings.objects.filter(user=sender_user).first()
|
||
)()
|
||
if sec_settings and sec_settings.require_omemo:
|
||
omemo_status = str(omemo_observation.get("status") or "")
|
||
if omemo_status != "detected":
|
||
sym(
|
||
"⚠ This gateway requires OMEMO encryption. "
|
||
"Your message was not delivered. "
|
||
"Please enable OMEMO in your XMPP client."
|
||
)
|
||
return
|
||
except Exception as exc:
|
||
self.log.warning("OMEMO policy check failed: %s", exc)
|
||
|
||
if recipient_jid == settings.XMPP_JID:
|
||
self.log.debug("Handling command message sent to gateway JID")
|
||
if body.startswith(".") or self._extract_totp_secret_candidate(body):
|
||
await self._route_gateway_command(
|
||
sender_user=sender_user,
|
||
body=body,
|
||
sender_jid=sender_jid,
|
||
recipient_jid=recipient_jid,
|
||
local_message=None,
|
||
message_meta={
|
||
"xmpp": {
|
||
"sender_jid": str(sender_jid or ""),
|
||
"recipient_jid": str(recipient_jid or ""),
|
||
"omemo_status": str(omemo_observation.get("status") or ""),
|
||
"omemo_client_key": str(omemo_observation.get("client_key") or ""),
|
||
}
|
||
},
|
||
sym=sym,
|
||
)
|
||
else:
|
||
self.log.debug("Handling routed message to contact")
|
||
if "|" in recipient_username:
|
||
recipient_name, recipient_service = recipient_username.split("|")
|
||
|
||
recipient_name = recipient_name.title()
|
||
|
||
else:
|
||
recipient_name = recipient_username
|
||
recipient_service = None
|
||
|
||
recipient_name = recipient_name.title()
|
||
|
||
try:
|
||
person = Person.objects.get(user=sender_user, name=recipient_name)
|
||
except Person.DoesNotExist:
|
||
sym("This person does not exist.")
|
||
|
||
if recipient_service:
|
||
try:
|
||
identifier = PersonIdentifier.objects.get(
|
||
user=sender_user, person=person, service=recipient_service
|
||
)
|
||
except PersonIdentifier.DoesNotExist:
|
||
sym("This service identifier does not exist.")
|
||
else:
|
||
# Get a random identifier
|
||
identifier = PersonIdentifier.objects.filter(
|
||
user=sender_user, person=person
|
||
).first()
|
||
recipient_service = identifier.service
|
||
|
||
# sym(str(person.__dict__))
|
||
# sym(f"Service: {recipient_service}")
|
||
|
||
if parsed_reaction or greentext_reaction:
|
||
# TODO(web-ui-react): expose explicit web compose reaction actions
|
||
# that call this same bridge path (without text heuristics).
|
||
# TODO(edit-sync): extend bridge mapping to include edit message ids
|
||
# and reconcile upstream edit capability differences in UI.
|
||
# TODO(retract-sync): propagate delete/retract state through this
|
||
# same mapping layer for protocol parity.
|
||
reaction_payload = parsed_reaction or {
|
||
"target_id": parsed_reply_target,
|
||
"emoji": str((greentext_reaction or {}).get("emoji") or ""),
|
||
"remove": False,
|
||
}
|
||
if not str(reaction_payload.get("target_id") or "").strip():
|
||
text_hint = str((greentext_reaction or {}).get("quoted_text") or "")
|
||
hint_match = transport.resolve_bridge_from_text_hint(
|
||
user_id=identifier.user_id,
|
||
person_id=identifier.person_id,
|
||
service=recipient_service,
|
||
text_hint=text_hint,
|
||
)
|
||
reaction_payload["target_id"] = str(
|
||
(hint_match or {}).get("xmpp_message_id") or ""
|
||
)
|
||
|
||
self.log.debug(
|
||
"reaction-bridge xmpp-inbound actor=%s service=%s target_xmpp_id=%s emoji=%s remove=%s via=%s",
|
||
sender_username,
|
||
recipient_service,
|
||
str(reaction_payload.get("target_id") or "") or "-",
|
||
str(reaction_payload.get("emoji") or "") or "-",
|
||
bool(reaction_payload.get("remove")),
|
||
"xmpp:reactions" if parsed_reaction else "greentext",
|
||
)
|
||
|
||
bridge = transport.resolve_bridge_from_xmpp(
|
||
user_id=identifier.user_id,
|
||
person_id=identifier.person_id,
|
||
service=recipient_service,
|
||
xmpp_message_id=str(reaction_payload.get("target_id") or ""),
|
||
)
|
||
if not bridge:
|
||
bridge = await history.resolve_bridge_ref(
|
||
user=identifier.user,
|
||
identifier=identifier,
|
||
source_service=recipient_service,
|
||
xmpp_message_id=str(reaction_payload.get("target_id") or ""),
|
||
)
|
||
if not bridge:
|
||
self.log.warning(
|
||
"reaction-bridge xmpp-resolve-miss actor=%s service=%s target_xmpp_id=%s",
|
||
sender_username,
|
||
recipient_service,
|
||
str(reaction_payload.get("target_id") or "") or "-",
|
||
)
|
||
sym("Could not find upstream message for this reaction.")
|
||
return
|
||
|
||
sent_ok = await transport.send_reaction(
|
||
recipient_service,
|
||
identifier.identifier,
|
||
emoji=str(reaction_payload.get("emoji") or ""),
|
||
target_message_id=str(
|
||
(bridge or {}).get("upstream_message_id") or ""
|
||
),
|
||
target_timestamp=int((bridge or {}).get("upstream_ts") or 0),
|
||
target_author=str((bridge or {}).get("upstream_author") or ""),
|
||
remove=bool(reaction_payload.get("remove")),
|
||
)
|
||
if not sent_ok:
|
||
self.log.warning(
|
||
"reaction-bridge upstream-send-failed actor=%s service=%s recipient=%s target_upstream_id=%s target_upstream_ts=%s",
|
||
sender_username,
|
||
recipient_service,
|
||
identifier.identifier,
|
||
str((bridge or {}).get("upstream_message_id") or "") or "-",
|
||
int((bridge or {}).get("upstream_ts") or 0),
|
||
)
|
||
sym("Upstream protocol did not accept this reaction.")
|
||
return
|
||
|
||
await history.apply_reaction(
|
||
user=identifier.user,
|
||
identifier=identifier,
|
||
target_message_id=str((bridge or {}).get("local_message_id") or ""),
|
||
target_ts=int((bridge or {}).get("upstream_ts") or 0),
|
||
emoji=str(reaction_payload.get("emoji") or ""),
|
||
source_service="xmpp",
|
||
actor=sender_username,
|
||
remove=bool(reaction_payload.get("remove")),
|
||
payload={
|
||
"target_xmpp_id": str(reaction_payload.get("target_id") or ""),
|
||
"xmpp_message_id": xmpp_message_id,
|
||
},
|
||
)
|
||
self.log.debug(
|
||
"reaction-bridge xmpp-apply-ok actor=%s service=%s local_message_id=%s",
|
||
sender_username,
|
||
recipient_service,
|
||
str((bridge or {}).get("local_message_id") or "") or "-",
|
||
)
|
||
return
|
||
|
||
# tss = await identifier.send(body, attachments=attachments)
|
||
# AM FIXING https://git.zm.is/XF/GIA/issues/5
|
||
session, _ = await sync_to_async(ChatSession.objects.get_or_create)(
|
||
identifier=identifier,
|
||
user=identifier.user,
|
||
)
|
||
self.log.debug("Storing outbound XMPP message in history")
|
||
reply_ref = reply_sync.extract_reply_ref(
|
||
"xmpp",
|
||
{
|
||
"reply_source_message_id": parsed_reply_target,
|
||
"reply_source_chat_id": str(sender_jid or ""),
|
||
},
|
||
)
|
||
reply_target = await reply_sync.resolve_reply_target(
|
||
identifier.user,
|
||
session,
|
||
reply_ref,
|
||
)
|
||
local_message = await history.store_message(
|
||
session=session,
|
||
sender="XMPP",
|
||
text=body,
|
||
ts=int(now().timestamp() * 1000),
|
||
outgoing=True,
|
||
source_service="xmpp",
|
||
source_message_id=xmpp_message_id,
|
||
source_chat_id=str(sender_jid or ""),
|
||
reply_to=reply_target,
|
||
reply_source_service=str(reply_ref.get("reply_source_service") or ""),
|
||
reply_source_message_id=str(
|
||
reply_ref.get("reply_source_message_id") or ""
|
||
),
|
||
message_meta={
|
||
"xmpp": {
|
||
"sender_jid": str(sender_jid or ""),
|
||
"recipient_jid": str(recipient_jid or ""),
|
||
"omemo_status": str(omemo_observation.get("status") or ""),
|
||
"omemo_client_key": str(omemo_observation.get("client_key") or ""),
|
||
}
|
||
},
|
||
)
|
||
self.log.debug("Stored outbound XMPP message in history")
|
||
await self.ur.message_received(
|
||
"xmpp",
|
||
identifier=identifier,
|
||
text=body,
|
||
ts=int(now().timestamp() * 1000),
|
||
payload={
|
||
"sender_jid": sender_jid,
|
||
"recipient_jid": recipient_jid,
|
||
},
|
||
local_message=local_message,
|
||
)
|
||
|
||
manipulations = Manipulation.objects.filter(
|
||
group__people=identifier.person,
|
||
user=identifier.user,
|
||
mode="mutate",
|
||
enabled=True,
|
||
)
|
||
self.log.debug("Found %s active manipulations", manipulations.count())
|
||
if not manipulations:
|
||
await self.ur.stopped_typing(
|
||
"xmpp",
|
||
identifier=identifier,
|
||
payload={"reason": "message_sent"},
|
||
)
|
||
await identifier.send(
|
||
relay_body,
|
||
attachments,
|
||
metadata={
|
||
"xmpp_source_id": xmpp_message_id,
|
||
"xmpp_source_ts": int(now().timestamp() * 1000),
|
||
"xmpp_body": relay_body,
|
||
"legacy_message_id": str(local_message.id),
|
||
},
|
||
)
|
||
self.log.debug("Message sent unaltered")
|
||
return
|
||
|
||
manip = manipulations.first()
|
||
chat_history = await history.get_chat_history(session)
|
||
await utils.update_last_interaction(session)
|
||
prompt = replies.generate_mutate_reply_prompt(
|
||
relay_body,
|
||
identifier.person,
|
||
manip,
|
||
chat_history,
|
||
)
|
||
self.log.debug("Running XMPP context prompt")
|
||
result = await ai.run_prompt(
|
||
prompt,
|
||
manip.ai,
|
||
operation="xmpp_mutate",
|
||
)
|
||
self.log.debug("Generated mutated response for XMPP message")
|
||
await history.store_own_message(
|
||
session=session,
|
||
text=result,
|
||
ts=int(now().timestamp() * 1000),
|
||
)
|
||
await self.ur.stopped_typing(
|
||
"xmpp",
|
||
identifier=identifier,
|
||
payload={"reason": "message_sent"},
|
||
)
|
||
await identifier.send(
|
||
result,
|
||
attachments,
|
||
metadata={
|
||
"xmpp_source_id": xmpp_message_id,
|
||
"xmpp_source_ts": int(now().timestamp() * 1000),
|
||
"xmpp_body": result,
|
||
"legacy_message_id": str(local_message.id),
|
||
},
|
||
)
|
||
self.log.debug("Message sent with modifications")
|
||
|
||
async def request_upload_slots(self, recipient_jid, attachments):
|
||
"""Requests upload slots for multiple attachments concurrently."""
|
||
upload_tasks = [
|
||
self.request_upload_slot(
|
||
recipient_jid, att["filename"], att["content_type"], att["size"]
|
||
)
|
||
for att in attachments
|
||
]
|
||
upload_slots = await asyncio.gather(*upload_tasks)
|
||
|
||
return [
|
||
(att, slot)
|
||
for att, slot in zip(attachments, upload_slots)
|
||
if slot is not None
|
||
]
|
||
|
||
async def upload_and_send(self, att, upload_slot, recipient_jid, sender_jid):
|
||
"""Uploads a file and immediately sends the corresponding XMPP message."""
|
||
upload_url, put_url, auth_header = upload_slot
|
||
try:
|
||
filename, content_type = validate_attachment_metadata(
|
||
filename=att.get("filename"),
|
||
content_type=att.get("content_type"),
|
||
size=att.get("size"),
|
||
)
|
||
except Exception as exc:
|
||
self.log.warning("xmpp blocked outbound attachment: %s", exc)
|
||
return None
|
||
headers = {"Content-Type": content_type}
|
||
if auth_header:
|
||
headers["Authorization"] = auth_header
|
||
|
||
async with aiohttp.ClientSession() as session:
|
||
try:
|
||
async with session.put(
|
||
put_url, data=att["content"], headers=headers
|
||
) as response:
|
||
if response.status not in (200, 201):
|
||
self.log.error(
|
||
f"Upload failed: {response.status} {await response.text()}"
|
||
)
|
||
return None
|
||
self.log.debug(
|
||
"Successfully uploaded %s to %s", filename, upload_url
|
||
)
|
||
|
||
# Send XMPP message immediately after successful upload
|
||
xmpp_msg_id = await self.send_xmpp_message(
|
||
recipient_jid, sender_jid, upload_url, attachment_url=upload_url
|
||
)
|
||
return {
|
||
"url": upload_url,
|
||
"xmpp_message_id": xmpp_msg_id,
|
||
}
|
||
|
||
except Exception as e:
|
||
self.log.error(f"Error uploading {att['filename']} to XMPP: {e}")
|
||
return None
|
||
|
||
async def send_xmpp_message(
|
||
self, recipient_jid, sender_jid, body_text, attachment_url=None
|
||
):
|
||
"""Sends an XMPP message with either text or an attachment URL."""
|
||
msg = self.make_message(mto=recipient_jid, mfrom=sender_jid, mtype="chat")
|
||
if not msg.get("id"):
|
||
msg["id"] = uuid.uuid4().hex
|
||
msg_id = str(msg.get("id") or "").strip()
|
||
msg["body"] = body_text # Body must contain only text or the URL
|
||
|
||
if attachment_url:
|
||
# Include <x><url> (XEP-0066) to ensure client compatibility
|
||
oob_element = ET.Element("{jabber:x:oob}x")
|
||
url_element = ET.SubElement(oob_element, "{jabber:x:oob}url")
|
||
url_element.text = attachment_url
|
||
msg.xml.append(oob_element)
|
||
|
||
self.log.debug("Sending XMPP message: %s", msg.xml)
|
||
|
||
# Attempt OMEMO encryption for text-only messages (not attachments).
|
||
if not attachment_url:
|
||
omemo_plugin = self._get_omemo_plugin()
|
||
if omemo_plugin:
|
||
try:
|
||
from slixmpp.jid import JID as _JID
|
||
encrypted_msgs, enc_errors = await omemo_plugin.encrypt_message(
|
||
msg, _JID(recipient_jid)
|
||
)
|
||
if enc_errors:
|
||
self.log.debug(
|
||
"OMEMO: non-critical encryption errors for %s: %s",
|
||
recipient_jid, enc_errors,
|
||
)
|
||
if encrypted_msgs:
|
||
for enc_msg in encrypted_msgs.values():
|
||
enc_msg.send()
|
||
self.log.debug("OMEMO: sent encrypted message to %s", recipient_jid)
|
||
return msg_id
|
||
except Exception as exc:
|
||
self.log.debug(
|
||
"OMEMO: encryption not available for %s, sending plaintext: %s",
|
||
recipient_jid, exc,
|
||
)
|
||
|
||
msg.send()
|
||
return msg_id
|
||
|
||
async def send_xmpp_reaction(
|
||
self,
|
||
recipient_jid,
|
||
sender_jid,
|
||
*,
|
||
target_xmpp_id: str,
|
||
emoji: str,
|
||
remove: bool = False,
|
||
):
|
||
msg = self.make_message(mto=recipient_jid, mfrom=sender_jid, mtype="chat")
|
||
if not msg.get("id"):
|
||
msg["id"] = uuid.uuid4().hex
|
||
msg["body"] = ""
|
||
reactions_node = ET.Element(
|
||
"{urn:xmpp:reactions:0}reactions",
|
||
{"id": str(target_xmpp_id or "").strip()},
|
||
)
|
||
if not remove and str(emoji or "").strip():
|
||
reaction_node = ET.SubElement(
|
||
reactions_node,
|
||
"{urn:xmpp:reactions:0}reaction",
|
||
)
|
||
reaction_node.text = str(emoji)
|
||
msg.xml.append(reactions_node)
|
||
msg.send()
|
||
return str(msg.get("id") or "").strip()
|
||
|
||
async def apply_external_reaction(
|
||
self,
|
||
user,
|
||
person_identifier,
|
||
*,
|
||
source_service,
|
||
emoji,
|
||
remove,
|
||
upstream_message_id="",
|
||
upstream_ts=0,
|
||
actor="",
|
||
payload=None,
|
||
):
|
||
self.log.debug(
|
||
"reaction-bridge external-in source=%s user=%s person=%s upstream_id=%s upstream_ts=%s emoji=%s remove=%s",
|
||
source_service,
|
||
user.id,
|
||
person_identifier.person_id,
|
||
str(upstream_message_id or "") or "-",
|
||
int(upstream_ts or 0),
|
||
str(emoji or "") or "-",
|
||
bool(remove),
|
||
)
|
||
bridge = transport.resolve_bridge_from_upstream(
|
||
user_id=user.id,
|
||
person_id=person_identifier.person_id,
|
||
service=source_service,
|
||
upstream_message_id=str(upstream_message_id or ""),
|
||
upstream_ts=int(upstream_ts or 0),
|
||
)
|
||
if not bridge:
|
||
bridge = await history.resolve_bridge_ref(
|
||
user=user,
|
||
identifier=person_identifier,
|
||
source_service=source_service,
|
||
upstream_message_id=str(upstream_message_id or ""),
|
||
upstream_author=str(actor or ""),
|
||
upstream_ts=int(upstream_ts or 0),
|
||
)
|
||
if not bridge:
|
||
self.log.warning(
|
||
"reaction-bridge external-resolve-miss source=%s user=%s person=%s upstream_id=%s upstream_ts=%s",
|
||
source_service,
|
||
user.id,
|
||
person_identifier.person_id,
|
||
str(upstream_message_id or "") or "-",
|
||
int(upstream_ts or 0),
|
||
)
|
||
return False
|
||
|
||
target_xmpp_id = str((bridge or {}).get("xmpp_message_id") or "").strip()
|
||
if not target_xmpp_id:
|
||
self.log.warning(
|
||
"reaction-bridge external-target-missing source=%s user=%s person=%s",
|
||
source_service,
|
||
user.id,
|
||
person_identifier.person_id,
|
||
)
|
||
return False
|
||
|
||
sender_jid = (
|
||
f"{person_identifier.person.name.lower()}|"
|
||
f"{person_identifier.service}@{settings.XMPP_JID}"
|
||
)
|
||
recipient_jid = self._user_jid(user.username)
|
||
await self.send_xmpp_reaction(
|
||
recipient_jid,
|
||
sender_jid,
|
||
target_xmpp_id=target_xmpp_id,
|
||
emoji=str(emoji or ""),
|
||
remove=bool(remove),
|
||
)
|
||
await history.apply_reaction(
|
||
user=user,
|
||
identifier=person_identifier,
|
||
target_message_id=str((bridge or {}).get("local_message_id") or ""),
|
||
target_ts=int((bridge or {}).get("upstream_ts") or 0),
|
||
emoji=str(emoji or ""),
|
||
source_service=source_service,
|
||
actor=str(actor or person_identifier.identifier),
|
||
remove=bool(remove),
|
||
payload=dict(payload or {}),
|
||
)
|
||
self.log.debug(
|
||
"reaction-bridge external-apply-ok source=%s user=%s person=%s xmpp_id=%s local_message_id=%s",
|
||
source_service,
|
||
user.id,
|
||
person_identifier.person_id,
|
||
target_xmpp_id,
|
||
str((bridge or {}).get("local_message_id") or "") or "-",
|
||
)
|
||
return True
|
||
|
||
async def send_chat_state(self, recipient_jid, sender_jid, started):
|
||
"""Send XMPP chat-state update to the client."""
|
||
msg = self.make_message(mto=recipient_jid, mfrom=sender_jid, mtype="chat")
|
||
state_tag = "composing" if started else "paused"
|
||
msg.xml.append(
|
||
ET.Element(f"{{http://jabber.org/protocol/chatstates}}{state_tag}")
|
||
)
|
||
self.log.debug(
|
||
"Sending XMPP chat-state %s: %s -> %s",
|
||
state_tag,
|
||
sender_jid,
|
||
recipient_jid,
|
||
)
|
||
msg.send()
|
||
|
||
async def send_typing_for_person(self, user, person_identifier, started):
|
||
sender_jid = (
|
||
f"{person_identifier.person.name.lower()}|"
|
||
f"{person_identifier.service}@{settings.XMPP_JID}"
|
||
)
|
||
recipient_jid = self._user_jid(user.username)
|
||
await self.send_chat_state(recipient_jid, sender_jid, started)
|
||
|
||
async def send_from_external(
|
||
self,
|
||
user,
|
||
person_identifier,
|
||
text,
|
||
is_outgoing_message,
|
||
attachments=[],
|
||
source_ref=None,
|
||
):
|
||
"""Handles sending XMPP messages with text and attachments."""
|
||
|
||
sender_jid = f"{person_identifier.person.name.lower()}|{person_identifier.service}@{settings.XMPP_JID}"
|
||
recipient_jid = self._user_jid(person_identifier.user.username)
|
||
if is_outgoing_message:
|
||
xmpp_id = await self.send_xmpp_message(
|
||
recipient_jid,
|
||
sender_jid,
|
||
f"YOU: {text}",
|
||
)
|
||
transport.record_bridge_mapping(
|
||
user_id=user.id,
|
||
person_id=person_identifier.person_id,
|
||
service=person_identifier.service,
|
||
xmpp_message_id=xmpp_id,
|
||
xmpp_ts=int(time.time() * 1000),
|
||
upstream_message_id=str(
|
||
(source_ref or {}).get("upstream_message_id") or ""
|
||
),
|
||
upstream_author=str((source_ref or {}).get("upstream_author") or ""),
|
||
upstream_ts=int((source_ref or {}).get("upstream_ts") or 0),
|
||
text_preview=str(text or ""),
|
||
local_message_id=str((source_ref or {}).get("legacy_message_id") or ""),
|
||
)
|
||
await history.save_bridge_ref(
|
||
user=user,
|
||
identifier=person_identifier,
|
||
source_service=person_identifier.service,
|
||
local_message_id=str((source_ref or {}).get("legacy_message_id") or ""),
|
||
local_ts=int(
|
||
(source_ref or {}).get("xmpp_source_ts") or int(time.time() * 1000)
|
||
),
|
||
xmpp_message_id=xmpp_id,
|
||
upstream_message_id=str(
|
||
(source_ref or {}).get("upstream_message_id") or ""
|
||
),
|
||
upstream_author=str((source_ref or {}).get("upstream_author") or ""),
|
||
upstream_ts=int((source_ref or {}).get("upstream_ts") or 0),
|
||
)
|
||
|
||
# Step 1: Send text message separately
|
||
elif text:
|
||
xmpp_id = await self.send_xmpp_message(recipient_jid, sender_jid, text)
|
||
transport.record_bridge_mapping(
|
||
user_id=user.id,
|
||
person_id=person_identifier.person_id,
|
||
service=person_identifier.service,
|
||
xmpp_message_id=xmpp_id,
|
||
xmpp_ts=int(time.time() * 1000),
|
||
upstream_message_id=str(
|
||
(source_ref or {}).get("upstream_message_id") or ""
|
||
),
|
||
upstream_author=str((source_ref or {}).get("upstream_author") or ""),
|
||
upstream_ts=int((source_ref or {}).get("upstream_ts") or 0),
|
||
text_preview=str(text or ""),
|
||
local_message_id=str((source_ref or {}).get("legacy_message_id") or ""),
|
||
)
|
||
await history.save_bridge_ref(
|
||
user=user,
|
||
identifier=person_identifier,
|
||
source_service=person_identifier.service,
|
||
local_message_id=str((source_ref or {}).get("legacy_message_id") or ""),
|
||
local_ts=int(
|
||
(source_ref or {}).get("xmpp_source_ts") or int(time.time() * 1000)
|
||
),
|
||
xmpp_message_id=xmpp_id,
|
||
upstream_message_id=str(
|
||
(source_ref or {}).get("upstream_message_id") or ""
|
||
),
|
||
upstream_author=str((source_ref or {}).get("upstream_author") or ""),
|
||
upstream_ts=int((source_ref or {}).get("upstream_ts") or 0),
|
||
)
|
||
|
||
if not attachments:
|
||
return [] # No attachments to process
|
||
|
||
# Step 2: Request upload slots concurrently
|
||
valid_uploads = await self.request_upload_slots(recipient_jid, attachments)
|
||
self.log.debug("Got upload slots")
|
||
if not valid_uploads:
|
||
self.log.debug("No valid upload slots obtained; attachment relay skipped")
|
||
return []
|
||
|
||
# Step 3: Upload each file and send its message immediately after upload
|
||
upload_tasks = [
|
||
self.upload_and_send(att, slot, recipient_jid, sender_jid)
|
||
for att, slot in valid_uploads
|
||
]
|
||
uploaded_rows = await asyncio.gather(*upload_tasks) # Upload files concurrently
|
||
normalized_rows = [dict(row or {}) for row in uploaded_rows if row]
|
||
for row in normalized_rows:
|
||
transport.record_bridge_mapping(
|
||
user_id=user.id,
|
||
person_id=person_identifier.person_id,
|
||
service=person_identifier.service,
|
||
xmpp_message_id=str(row.get("xmpp_message_id") or "").strip(),
|
||
xmpp_ts=int(time.time() * 1000),
|
||
upstream_message_id=str(
|
||
(source_ref or {}).get("upstream_message_id") or ""
|
||
),
|
||
upstream_author=str((source_ref or {}).get("upstream_author") or ""),
|
||
upstream_ts=int((source_ref or {}).get("upstream_ts") or 0),
|
||
text_preview=str(row.get("url") or text or ""),
|
||
local_message_id=str((source_ref or {}).get("legacy_message_id") or ""),
|
||
)
|
||
await history.save_bridge_ref(
|
||
user=user,
|
||
identifier=person_identifier,
|
||
source_service=person_identifier.service,
|
||
local_message_id=str((source_ref or {}).get("legacy_message_id") or ""),
|
||
local_ts=int(
|
||
(source_ref or {}).get("xmpp_source_ts") or int(time.time() * 1000)
|
||
),
|
||
xmpp_message_id=str(row.get("xmpp_message_id") or "").strip(),
|
||
upstream_message_id=str(
|
||
(source_ref or {}).get("upstream_message_id") or ""
|
||
),
|
||
upstream_author=str((source_ref or {}).get("upstream_author") or ""),
|
||
upstream_ts=int((source_ref or {}).get("upstream_ts") or 0),
|
||
)
|
||
return [
|
||
str(row.get("url") or "").strip()
|
||
for row in normalized_rows
|
||
if str(row.get("url") or "").strip()
|
||
]
|
||
|
||
|
||
class XMPPClient(ClientBase):
|
||
def __init__(self, ur, *args, **kwargs):
|
||
super().__init__(ur, *args, **kwargs)
|
||
self._enabled = True
|
||
self.client = None
|
||
jid = str(getattr(settings, "XMPP_JID", "") or "").strip()
|
||
secret = str(getattr(settings, "XMPP_SECRET", "") or "").strip()
|
||
server = str(getattr(settings, "XMPP_ADDRESS", "") or "").strip()
|
||
port = int(getattr(settings, "XMPP_PORT", 8888) or 8888)
|
||
missing = []
|
||
if not jid:
|
||
missing.append("XMPP_JID")
|
||
if not secret:
|
||
missing.append("XMPP_SECRET")
|
||
if not server:
|
||
missing.append("XMPP_ADDRESS")
|
||
if missing:
|
||
self._enabled = False
|
||
self.log.warning(
|
||
"XMPP client disabled due to missing configuration: %s",
|
||
", ".join(missing),
|
||
)
|
||
|
||
if self._enabled:
|
||
self.client = XMPPComponent(
|
||
ur,
|
||
jid=jid,
|
||
secret=secret,
|
||
server=server,
|
||
port=port,
|
||
)
|
||
|
||
self.client.register_plugin("xep_0030") # Service Discovery
|
||
self.client.register_plugin("xep_0004") # Data Forms
|
||
self.client.register_plugin("xep_0060") # PubSub
|
||
self.client.register_plugin("xep_0199") # XMPP Ping
|
||
self.client.register_plugin("xep_0085") # Chat State Notifications
|
||
self.client.register_plugin("xep_0363") # HTTP File Upload
|
||
|
||
self._omemo_plugin_registered = False
|
||
if _OMEMO_AVAILABLE:
|
||
try:
|
||
data_dir = str(getattr(settings, "XMPP_OMEMO_DATA_DIR", "") or "").strip()
|
||
if not data_dir:
|
||
data_dir = str(Path(settings.BASE_DIR) / "xmpp_omemo_data")
|
||
# Register our concrete plugin class under the "xep_0384" name so
|
||
# that slixmpp's dependency resolver finds it.
|
||
_slixmpp_register_plugin(_GiaOmemoPlugin)
|
||
self.client.register_plugin("xep_0384", pconfig={"data_dir": data_dir})
|
||
self._omemo_plugin_registered = True
|
||
self.log.info("OMEMO: xep_0384 plugin registered, data_dir=%s", data_dir)
|
||
except Exception as exc:
|
||
self.log.warning("OMEMO: failed to register xep_0384 plugin: %s", exc)
|
||
else:
|
||
self.log.warning("OMEMO: slixmpp_omemo not available, OMEMO disabled")
|
||
|
||
def start(self):
|
||
if not self._enabled or self.client is None:
|
||
return
|
||
self.log.info("XMPP client starting...")
|
||
|
||
# ensure slixmpp uses the same asyncio loop as the router
|
||
self.client.loop = self.loop
|
||
|
||
self.client.connect()
|
||
|
||
async def start_typing_for_person(self, user, person_identifier):
|
||
if self.client is None:
|
||
return
|
||
await self.client.send_typing_for_person(user, person_identifier, True)
|
||
|
||
async def stop_typing_for_person(self, user, person_identifier):
|
||
if self.client is None:
|
||
return
|
||
await self.client.send_typing_for_person(user, person_identifier, False)
|