import asyncio import re 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.stanza import Message from slixmpp.xmlstream import register_stanza_plugin from slixmpp.xmlstream.stanzabase import ET from core.clients import ClientBase from core.messaging import ai, history, replies, utils from core.models import ( ChatSession, Manipulation, PatternMitigationAutoSettings, PatternMitigationCorrection, PatternMitigationGame, PatternMitigationPlan, PatternMitigationRule, Person, PersonIdentifier, User, WorkspaceConversation, ) from core.util import logs URL_PATTERN = re.compile(r"https?://[^\s<>'\"\\]+") 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 _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 class XMPPComponent(ComponentXMPP): """ A simple Slixmpp component that echoes messages. """ def __init__(self, ur, jid, secret, server, port): self.ur = ur self.log = logs.get_logger("XMPP") super().__init__(jid, secret, server, port) # 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) 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("|") person_name = person_name.title() # Capitalize for consistency else: person_name = recipient_username.title() service = None try: # Lookup user in Django self.log.info(f"User {sender_username}") user = User.objects.get(username=sender_username) # Find Person object with name=person_name.lower() self.log.info(f"Name {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.info(f"Identifier {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 async def _handle_mitigation_command(self, sender_user, body, sym): def parse_parts(raw): return [part.strip() for part in raw.split("|")] command = body.strip() if command == ".mitigation help": sym( "Mitigation commands: " ".mitigation list | " ".mitigation show | " ".mitigation rule-add ||<content> | " ".mitigation rule-del <person>|<title> | " ".mitigation game-add <person>|<title>|<instructions> | " ".mitigation game-del <person>|<title> | " ".mitigation correction-add <person>|<title>|<clarification> | " ".mitigation correction-del <person>|<title> | " ".mitigation fundamentals-set <person>|<item1;item2;...> | " ".mitigation plan-set <person>|<draft|active|archived>|<auto|guided> | " ".mitigation auto <person>|on|off | " ".mitigation auto-status <person>" ) return True if command == ".mitigation list": plans = await sync_to_async(list)( PatternMitigationPlan.objects.filter(user=sender_user) .select_related("conversation") .order_by("-updated_at")[:15] ) if not plans: sym("No mitigation plans found.") return True rows = [] for plan in plans: person_name = ( plan.conversation.participants.order_by("name").first().name if plan.conversation.participants.exists() else "Unknown" ) rows.append(f"{person_name}: {plan.title}") sym("Plans: " + " | ".join(rows)) return True if command.startswith(".mitigation show "): person_name = command.replace(".mitigation show ", "", 1).strip().title() person = await sync_to_async( lambda: Person.objects.filter( user=sender_user, name__iexact=person_name ).first() )() if not person: sym("Unknown person.") return True plan = await sync_to_async(self._get_or_create_plan)(sender_user, person) rule_count = await sync_to_async(plan.rules.count)() game_count = await sync_to_async(plan.games.count)() sym(f"{person.name}: {plan.title} | rules={rule_count} games={game_count}") return True if command.startswith(".mitigation rule-add "): payload = command.replace(".mitigation rule-add ", "", 1) parts = parse_parts(payload) if len(parts) < 3: sym("Usage: .mitigation rule-add <person>|<title>|<content>") return True person_name, title, content = ( parts[0].title(), parts[1], "|".join(parts[2:]), ) person = await sync_to_async( lambda: Person.objects.filter( user=sender_user, name__iexact=person_name ).first() )() if not person: sym("Unknown person.") return True plan = await sync_to_async(self._get_or_create_plan)(sender_user, person) await sync_to_async(PatternMitigationRule.objects.create)( user=sender_user, plan=plan, title=title[:255], content=content, enabled=True, ) sym("Rule added.") return True if command.startswith(".mitigation rule-del "): payload = command.replace(".mitigation rule-del ", "", 1) parts = parse_parts(payload) if len(parts) < 2: sym("Usage: .mitigation rule-del <person>|<title>") return True person_name, title = parts[0].title(), "|".join(parts[1:]) person = await sync_to_async( lambda: Person.objects.filter( user=sender_user, name__iexact=person_name ).first() )() if not person: sym("Unknown person.") return True plan = await sync_to_async(self._get_or_create_plan)(sender_user, person) deleted, _ = await sync_to_async( lambda: PatternMitigationRule.objects.filter( user=sender_user, plan=plan, title__iexact=title, ).delete() )() sym("Rule deleted." if deleted else "Rule not found.") return True if command.startswith(".mitigation game-add "): payload = command.replace(".mitigation game-add ", "", 1) parts = parse_parts(payload) if len(parts) < 3: sym("Usage: .mitigation game-add <person>|<title>|<instructions>") return True person_name, title, content = ( parts[0].title(), parts[1], "|".join(parts[2:]), ) person = await sync_to_async( lambda: Person.objects.filter( user=sender_user, name__iexact=person_name ).first() )() if not person: sym("Unknown person.") return True plan = await sync_to_async(self._get_or_create_plan)(sender_user, person) await sync_to_async(PatternMitigationGame.objects.create)( user=sender_user, plan=plan, title=title[:255], instructions=content, enabled=True, ) sym("Game added.") return True if command.startswith(".mitigation game-del "): payload = command.replace(".mitigation game-del ", "", 1) parts = parse_parts(payload) if len(parts) < 2: sym("Usage: .mitigation game-del <person>|<title>") return True person_name, title = parts[0].title(), "|".join(parts[1:]) person = await sync_to_async( lambda: Person.objects.filter( user=sender_user, name__iexact=person_name ).first() )() if not person: sym("Unknown person.") return True plan = await sync_to_async(self._get_or_create_plan)(sender_user, person) deleted, _ = await sync_to_async( lambda: PatternMitigationGame.objects.filter( user=sender_user, plan=plan, title__iexact=title, ).delete() )() sym("Game deleted." if deleted else "Game not found.") return True if command.startswith(".mitigation correction-add "): payload = command.replace(".mitigation correction-add ", "", 1) parts = parse_parts(payload) if len(parts) < 3: sym( "Usage: .mitigation correction-add <person>|<title>|<clarification>" ) return True person_name, title, clarification = ( parts[0].title(), parts[1], "|".join(parts[2:]), ) person = await sync_to_async( lambda: Person.objects.filter( user=sender_user, name__iexact=person_name ).first() )() if not person: sym("Unknown person.") return True plan = await sync_to_async(self._get_or_create_plan)(sender_user, person) await sync_to_async(PatternMitigationCorrection.objects.create)( user=sender_user, plan=plan, title=title[:255], clarification=clarification, source_phrase="", perspective="second_person", share_target="both", language_style="adapted", enabled=True, ) sym("Correction added.") return True if command.startswith(".mitigation correction-del "): payload = command.replace(".mitigation correction-del ", "", 1) parts = parse_parts(payload) if len(parts) < 2: sym("Usage: .mitigation correction-del <person>|<title>") return True person_name, title = parts[0].title(), "|".join(parts[1:]) person = await sync_to_async( lambda: Person.objects.filter( user=sender_user, name__iexact=person_name ).first() )() if not person: sym("Unknown person.") return True plan = await sync_to_async(self._get_or_create_plan)(sender_user, person) deleted, _ = await sync_to_async( lambda: PatternMitigationCorrection.objects.filter( user=sender_user, plan=plan, title__iexact=title, ).delete() )() sym("Correction deleted." if deleted else "Correction not found.") return True if command.startswith(".mitigation fundamentals-set "): payload = command.replace(".mitigation fundamentals-set ", "", 1) parts = parse_parts(payload) if len(parts) < 2: sym("Usage: .mitigation fundamentals-set <person>|<item1;item2;...>") return True person_name, values = parts[0].title(), "|".join(parts[1:]) person = await sync_to_async( lambda: Person.objects.filter( user=sender_user, name__iexact=person_name ).first() )() if not person: sym("Unknown person.") return True plan = await sync_to_async(self._get_or_create_plan)(sender_user, person) items = [item.strip() for item in values.split(";") if item.strip()] plan.fundamental_items = items await sync_to_async(plan.save)( update_fields=["fundamental_items", "updated_at"] ) sym(f"Fundamentals updated ({len(items)}).") return True if command.startswith(".mitigation plan-set "): payload = command.replace(".mitigation plan-set ", "", 1) parts = parse_parts(payload) if len(parts) < 3: sym( "Usage: .mitigation plan-set <person>|<draft|active|archived>|<auto|guided>" ) return True person_name, status_value, mode_value = ( parts[0].title(), parts[1].lower(), parts[2].lower(), ) person = await sync_to_async( lambda: Person.objects.filter( user=sender_user, name__iexact=person_name ).first() )() if not person: sym("Unknown person.") return True plan = await sync_to_async(self._get_or_create_plan)(sender_user, person) valid_status = {key for key, _ in PatternMitigationPlan.STATUS_CHOICES} valid_modes = { key for key, _ in PatternMitigationPlan.CREATION_MODE_CHOICES } if status_value in valid_status: plan.status = status_value if mode_value in valid_modes: plan.creation_mode = mode_value await sync_to_async(plan.save)( update_fields=["status", "creation_mode", "updated_at"] ) sym(f"Plan updated: status={plan.status}, mode={plan.creation_mode}") return True if command.startswith(".mitigation auto "): payload = command.replace(".mitigation auto ", "", 1) parts = parse_parts(payload) if len(parts) < 2: sym("Usage: .mitigation auto <person>|on|off") return True person_name, state = parts[0].title(), parts[1].lower() person = await sync_to_async( lambda: Person.objects.filter( user=sender_user, name__iexact=person_name ).first() )() if not person: sym("Unknown person.") return True conversation = await sync_to_async(self._get_workspace_conversation)( sender_user, person ) auto_obj, _ = await sync_to_async( PatternMitigationAutoSettings.objects.get_or_create )( user=sender_user, conversation=conversation, ) auto_obj.enabled = state in {"on", "true", "1", "yes"} await sync_to_async(auto_obj.save)(update_fields=["enabled", "updated_at"]) sym( f"Automation {'enabled' if auto_obj.enabled else 'disabled'} for {person.name}." ) return True if command.startswith(".mitigation auto-status "): person_name = ( command.replace(".mitigation auto-status ", "", 1).strip().title() ) person = await sync_to_async( lambda: Person.objects.filter( user=sender_user, name__iexact=person_name ).first() )() if not person: sym("Unknown person.") return True conversation = await sync_to_async(self._get_workspace_conversation)( sender_user, person ) auto_obj, _ = await sync_to_async( PatternMitigationAutoSettings.objects.get_or_create )( user=sender_user, conversation=conversation, ) sym( f"{person.name}: auto={'on' if auto_obj.enabled else 'off'}, " f"pattern={'on' if auto_obj.auto_pattern_recognition else 'off'}, " f"corrections={'on' if auto_obj.auto_create_corrections else 'off'}" ) return True return False def update_roster(self, jid, name=None): """ Adds or updates a user in the roster. """ iq = self.Iq() iq["type"] = "set" iq["roster"]["items"] = {jid: {"name": name or jid}} iq.send() self.log.info(f"Updated roster: Added {jid} ({name})") def on_chatstate_active(self, msg): """ Handle when a user is actively engaged in the chat. """ self.log.info(f"Chat state: Active from {msg['from']}.") self.get_identifier(msg) def on_chatstate_composing(self, msg): """ Handle when a user is typing a message. """ self.log.info(f"Chat state: Composing from {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.info(f"Chat state: Paused from {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.info(f"Chat state: Inactive from {msg['from']}.") self.get_identifier(msg) def on_chatstate_gone(self, msg): """ Handle when a user has left the chat. """ self.log.info(f"Chat state: Gone from {msg['from']}.") self.get_identifier(msg) def on_presence_available(self, pres): """ Handle when a user becomes available. """ self.log.info(f"Presence available from {pres['from']}") def on_presence_dnd(self, pres): """ Handle when a user sets 'Do Not Disturb' status. """ self.log.info(f"User {pres['from']} is now in 'Do Not Disturb' mode.") def on_presence_xa(self, pres): """ Handle when a user sets 'Extended Away' status. """ self.log.info(f"User {pres['from']} is now 'Extended Away'.") def on_presence_chat(self, pres): """ Handle when a user is actively available for chat. """ self.log.info(f"User {pres['from']} is now available for chat.") def on_presence_away(self, pres): """ Handle when a user sets 'Away' status. """ self.log.info(f"User {pres['from']} is now 'Away'.") def on_presence_unavailable(self, pres): """ Handle when a user goes offline or unavailable. """ self.log.info(f"User {pres['from']} is now unavailable.") 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.info( 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.info(f"User {user_username}") user = User.objects.get(username=user_username) # Find Person object with name=person_name.lower() self.log.info(f"Name {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.info(f"Identifier {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.info( 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) # self.log.info(f"Sent presence subscription request from {component_jid} to {sender_jid}") # Add sender to roster # self.update_roster(sender_jid, name=sender_jid.split("@")[0]) # self.log.info(f"Added {sender_jid} to roster.") # Send presence update to sender **from the correct JID** self.send_presence(ptype="available", pto=sender_jid, pfrom=component_jid) self.log.info(f"Sent presence update from {component_jid} to {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.info(f"Subscription to {pres['from']} was accepted.") def on_presence_unsubscribe(self, pres): """ Handle when a user unsubscribes from presence updates. """ self.log.info(f"User {pres['from']} has unsubscribed from presence updates.") def on_presence_unsubscribed(self, pres): """ Handle when a user's unsubscription request is confirmed. """ self.log.info(f"Unsubscription from {pres['from']} confirmed.") def on_roster_subscription_request(self, pres): """ Handle roster subscription requests. """ self.log.info(f"New roster subscription request from {pres['from']}.") async def session_start(self, *args): self.log.info("XMPP session started") await self.enable_carbons() def on_disconnected(self, *args): """ Handles XMPP disconnection and triggers a reconnect loop. """ self.log.warning("XMPP disconnected, attempting to reconnect...") self.connect() 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 # self.log.info(f"Upload service: {upload_service}") upload_service_jid = "share.zm.is" 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() # self.log.info(f"Received message: {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) 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 # Extract message body body = msg["body"] if msg["body"] else "" attachments = [] self.log.info(f"Full XMPP Message: {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 attachments.append( { "url": url_value, "filename": att.attrib.get("filename") or _filename_from_url(url_value), "content_type": att.attrib.get("content_type") or "application/octet-stream", } ) # 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 attachments.append( { "url": url_value, "filename": _filename_from_url(url_value), "content_type": "application/octet-stream", } ) # 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 attachments.append( { "url": url_value, "filename": _filename_from_url(url_value), "content_type": "application/octet-stream", } ) 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) self.log.info(f"Extracted {len(attachments)} attachments from XMPP message.") # 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.info(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 if recipient_jid == settings.XMPP_JID: self.log.info("Message to JID") if body.startswith("."): # Messaging the gateway directly if body == ".contacts": # Lookup Person objects linked to sender persons = Person.objects.filter(user=sender_user) if not persons.exists(): self.log.info(f"No contacts found for {sender_username}") sym("No contacts found.") return # Construct contact list response contact_names = [person.name for person in persons] response_text = "Contacts: " + ", ".join(contact_names) sym(response_text) elif body == ".help": sym("Commands: .contacts, .whoami, .mitigation help") elif body.startswith(".mitigation"): handled = await self._handle_mitigation_command( sender_user, body, sym, ) if not handled: sym("Unknown mitigation command. Try .mitigation help") elif body == ".whoami": sym(str(sender_user.__dict__)) else: sym("No such command") else: self.log.info("Other message") 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}") # 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.info(f"Component history store message {body}") await history.store_message( session=session, sender="XMPP", text=body, ts=int(now().timestamp() * 1000), # outgoing=detail.is_outgoing_message, ????????? TODO: ) self.log.info("Stored a message sent from XMPP in the history.") manipulations = Manipulation.objects.filter( group__people=identifier.person, user=identifier.user, mode="mutate", enabled=True, ) self.log.info(f"MANIP11 {manipulations}") if not manipulations: await self.ur.stopped_typing( "xmpp", identifier=identifier, payload={"reason": "message_sent"}, ) await identifier.send( body, attachments, ) self.log.info("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( body, identifier.person, manip, chat_history, ) self.log.info("Running XMPP context prompt") result = await ai.run_prompt(prompt, manip.ai) self.log.info(f"RESULT {result}") 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, ) self.log.info("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 headers = {"Content-Type": att["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 self.log.info( f"Successfully uploaded {att['filename']} to {upload_url}" ) # Send XMPP message immediately after successful upload await self.send_xmpp_message( recipient_jid, sender_jid, upload_url, attachment_url=upload_url ) except Exception as e: self.log.error(f"Error uploading {att['filename']} to XMPP: {e}") 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") 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.info(f"Sending XMPP message: {msg.xml}") msg.send() 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.info( "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 = f"{user.username}@{settings.XMPP_ADDRESS}" await self.send_chat_state(recipient_jid, sender_jid, started) async def send_from_external( self, user, person_identifier, text, is_outgoing_message, attachments=[] ): """Handles sending XMPP messages with text and attachments.""" sender_jid = f"{person_identifier.person.name.lower()}|{person_identifier.service}@{settings.XMPP_JID}" recipient_jid = f"{person_identifier.user.username}@{settings.XMPP_ADDRESS}" if is_outgoing_message: await self.send_xmpp_message(recipient_jid, sender_jid, f"YOU: {text}") # Step 1: Send text message separately elif text: await self.send_xmpp_message(recipient_jid, sender_jid, text) 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.info("Got upload slots") if not valid_uploads: self.log.warning("No valid upload slots obtained.") # 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 ] await asyncio.gather(*upload_tasks) # Upload files concurrently class XMPPClient(ClientBase): def __init__(self, ur, *args, **kwargs): super().__init__(ur, *args, **kwargs) self.client = XMPPComponent( ur, jid=settings.XMPP_JID, secret=settings.XMPP_SECRET, server=settings.XMPP_ADDRESS, port=settings.XMPP_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 def start(self): self.log.info("XMPP client starting...") # ensure slixmpp uses the same asyncio loop as the router self.client.loop = self.loop self.client.connect() # self.client.process() async def start_typing_for_person(self, user, person_identifier): await self.client.send_typing_for_person(user, person_identifier, True) async def stop_typing_for_person(self, user, person_identifier): await self.client.send_typing_for_person(user, person_identifier, False)