Implement bridging Signal and XMPP
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -69,6 +69,20 @@ class HandleMessage(Command):
|
||||
|
||||
# Determine the identifier to use
|
||||
identifier_uuid = dest if is_from_bot else source_uuid
|
||||
|
||||
|
||||
# Handle attachments
|
||||
attachments = raw.get("envelope", {}).get("syncMessage", {}).get("sentMessage", {}).get("attachments", [])
|
||||
attachment_list = []
|
||||
for attachment in attachments:
|
||||
attachment_list.append({
|
||||
"id": attachment["id"],
|
||||
"content_type": attachment["contentType"],
|
||||
"filename": attachment["filename"],
|
||||
"size": attachment["size"],
|
||||
"width": attachment.get("width"),
|
||||
"height": attachment.get("height"),
|
||||
})
|
||||
|
||||
cast = {
|
||||
"type": "def",
|
||||
@@ -77,6 +91,7 @@ class HandleMessage(Command):
|
||||
# "sender": source_uuid,
|
||||
"identifier": identifier_uuid,
|
||||
"msg": text,
|
||||
"attachments": attachment_list,
|
||||
"detail": {
|
||||
"reply_to_self": reply_to_self,
|
||||
"reply_to_others": reply_to_others,
|
||||
|
||||
Reference in New Issue
Block a user