Implement bridging Signal and XMPP

This commit is contained in:
2025-02-23 18:34:03 +00:00
parent 8d2f28f571
commit b2b44c31cc
5 changed files with 325 additions and 46 deletions

View File

@@ -10,11 +10,19 @@ from core.lib import deferred
from slixmpp.xmlstream import register_stanza_plugin
from slixmpp.plugins.xep_0085.stanza import Active, Composing, Paused, Inactive, Gone
from slixmpp.stanza import Message
from slixmpp.xmlstream.stanzabase import ElementBase, ET
import aiohttp
log = logs.get_logger("component")
redis = aioredis.from_url("unix://var/run/gia-redis.sock", db=10)
class Attachment(ElementBase):
name = "attachment"
namespace = "urn:xmpp:attachments"
plugin_attrib = "attachment"
interfaces = {"url", "filename", "content_type"}
class EchoComponent(ComponentXMPP):
"""
@@ -104,6 +112,17 @@ class EchoComponent(ComponentXMPP):
# If any lookup fails, reject the subscription
return None
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()
log.info(f"Updated roster: Added {jid} ({name})")
def on_chatstate_active(self, msg):
"""
Handle when a user is actively engaged in the chat.
@@ -217,9 +236,24 @@ class EchoComponent(ComponentXMPP):
log.info(f"Identifier {service}")
PersonIdentifier.objects.get(user=user, person=person, service=service)
# If all checks pass, accept the subscription
self.send_presence(ptype="subscribed", pto=sender_jid)
log.info(f"Subscription request from {sender_jid} accepted for {recipient_jid}.")
component_jid = f"{person_name.lower()}|{service}@{self.boundjid.bare}"
# Accept the subscription
self.send_presence(ptype="subscribed", pto=sender_jid, pfrom=component_jid)
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)
# 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])
# 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)
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
@@ -264,6 +298,69 @@ class EchoComponent(ComponentXMPP):
def session_start(self, *args):
log.info(f"START {args}")
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:
# log.error("No XEP-0363 upload service found.")
# return None
#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:
log.error(f"Failed to obtain upload slot for {filename}")
return None
log.info(f"Slot: {slot}")
# 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")
log.info(f"Put element: {ET.tostring(put_element, encoding='unicode')}") # Debugging
# 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:
log.error(f"Missing URLs in upload slot: {slot}")
return None
log.info(f"Got upload slot: {get_url}, Authorization: {auth_header}")
return get_url, put_url, auth_header
except Exception as e:
log.error(f"Exception while requesting upload slot: {e}")
return None
def message(self, msg):
"""
Process incoming XMPP messages.
@@ -294,6 +391,26 @@ class EchoComponent(ComponentXMPP):
# Extract message body
body = msg["body"] if msg["body"] else "[No Body]"
attachments = []
log.info(f"Full XMPP Message: {ET.tostring(msg.xml, encoding='unicode')}")
# Extract attachments from standard XMPP <attachments> (if present)
for att in msg.xml.findall(".//{urn:xmpp:attachments}attachment"):
attachments.append({
"url": att.attrib.get("url"),
"filename": att.attrib.get("filename"),
"content_type": att.attrib.get("content_type"),
})
# Extract attachments from XEP-0066 <x><url> format (Out of Band Data)
for oob in msg.xml.findall(".//{jabber:x:oob}x/{jabber:x:oob}url"):
attachments.append({
"url": oob.text,
"filename": oob.text.split("/")[-1], # Extract filename from URL
"content_type": "application/octet-stream", # Generic content-type
})
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}, "
@@ -370,36 +487,69 @@ class EchoComponent(ComponentXMPP):
# sym(str(person.__dict__))
# sym(f"Service: {recipient_service}")
identifier.send(body)
def send_from_external(self, person_identifier, text, detail):
"""
This method will send an incoming external message to the correct XMPP user.
"""
identifier.send(body, attachments=attachments)
async def send_from_external(self, person_identifier, text, detail, 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 detail.is_outgoing_message:
carbon_msg = f"""
<message xmlns="jabber:client" type="chat" from="{recipient_jid}" to="{sender_jid}">
<received xmlns="urn:xmpp:carbons:2">
<forwarded xmlns="urn:xmpp:forward:0">
<message from="{recipient_jid}" to="{sender_jid}" type="chat">
<body>{text}</body>
</message>
</forwarded>
</received>
</message>
"""
log.info(f"Sending Carbon: {carbon_msg}")
self.send_raw(carbon_msg)
else:
# First, send text separately if there's any
if text:
text_msg = self.make_message(mto=recipient_jid, mfrom=sender_jid, mtype="chat")
text_msg["body"] = text
log.info(f"Sending separate text message: {text}")
if detail.is_outgoing_message:
log.info("OUT")
...
else:
log.info(f"Final XMPP message: {text_msg.xml}")
log.info(f"Sending message")
text_msg.send()
for att in attachments:
# Request an upload slot
upload_slot = await self.request_upload_slot(
recipient_jid, att["filename"], att["content_type"], att["size"]
)
if not upload_slot:
log.warning(f"Failed to obtain upload slot for {att['filename']}")
continue
log.info(f"Forwarding message from external service: {sender_jid} -> {recipient_jid}: {text}")
self.send_message(mto=recipient_jid, mfrom=sender_jid, mbody=text, mtype="chat")
upload_url, put_url, auth_header = upload_slot
# Upload file
headers = {"Content-Type": att["content_type"]}
if auth_header:
headers["Authorization"] = auth_header
try:
async with aiohttp.ClientSession() as session:
async with session.put(put_url, data=att["content"], headers=headers) as response:
if response.status not in (200, 201):
log.error(f"Upload failed: {response.status} {await response.text()}")
continue
# Create and send message with only the file URL
msg = self.make_message(mto=recipient_jid, mfrom=sender_jid, mtype="chat")
msg["body"] = upload_url # Body must be only the 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 = upload_url
msg.xml.append(oob_element)
log.info(f"Sending file attachment message with URL: {upload_url}")
if detail.is_outgoing_message:
log.info("OUT")
...
else:
log.info(f"Final XMPP message: {msg.xml}")
log.info(f"Sending message")
msg.send()
except Exception as e:
log.error(f"Error uploading {att['filename']} to XMPP: {e}")
async def stream(**kwargs):
pubsub = redis.pubsub()
@@ -437,6 +587,7 @@ class Command(BaseCommand):
xmpp.register_plugin('xep_0060') # PubSub
xmpp.register_plugin('xep_0199') # XMPP Ping
xmpp.register_plugin("xep_0085") # Chat State Notifications
xmpp.register_plugin('xep_0363') # HTTP File Upload
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)