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

@@ -6,6 +6,7 @@ from requests.exceptions import RequestException
import orjson
from django.conf import settings
import aiohttp
import base64
async def start_typing(uuid):
@@ -47,13 +48,87 @@ async def send_message_raw(recipient_uuid, text):
else:
return False
def send_message_raw_sync(recipient_uuid, text):
async def fetch_signal_attachment(attachment_id):
"""
Sends a message using the Signal REST API in a synchronous manner.
Asynchronously fetches an attachment from Signal.
Args:
attachment_id (str): The Signal attachment ID.
Returns:
dict | None:
{
"content": <binary file data>,
"content_type": <MIME type>,
"filename": <original filename (if available)>
}
or None if the request fails.
"""
url = f"http://signal:8080/v1/attachments/{attachment_id}"
try:
async with aiohttp.ClientSession() as session:
async with session.get(url, timeout=10) as response:
if response.status != 200:
return None # Failed request
content_type = response.headers.get("Content-Type", "application/octet-stream")
content = await response.read()
size = int(response.headers.get("Content-Length", len(content)))
filename = attachment_id # Default fallback filename
content_disposition = response.headers.get("Content-Disposition")
if content_disposition:
parts = content_disposition.split(";")
for part in parts:
if "filename=" in part:
filename = part.split("=", 1)[1].strip().strip('"')
return {
"content": content,
"content_type": content_type,
"filename": filename,
"size": size,
}
except aiohttp.ClientError:
return None # Network error
def download_and_encode_base64(file_url, filename, content_type):
"""
Downloads a file from a given URL, converts it to Base64, and returns it in Signal's expected format.
Args:
file_url (str): The URL of the file to download.
filename (str): The name of the file.
content_type (str): The MIME type of the file.
Returns:
str: The Base64 encoded attachment string in Signal's expected format.
"""
try:
response = requests.get(file_url, timeout=10)
response.raise_for_status()
file_data = response.content
base64_encoded = base64.b64encode(file_data).decode("utf-8")
# Format according to Signal's expected structure
return f"data:{content_type};filename={filename};base64,{base64_encoded}"
except requests.RequestException as e:
#log.error(f"Failed to download file: {file_url}, error: {e}")
return None
def send_message_raw_sync(recipient_uuid, text=None, attachments=[]):
"""
Sends a message using the Signal REST API, ensuring attachment links are not included in the text body.
Args:
recipient_uuid (str): The UUID of the recipient.
text (str): The message to send.
text (str, optional): The message to send.
attachments (list, optional): A list of attachment dictionaries with URL, filename, and content_type.
Returns:
int | bool: Timestamp if successful, False otherwise.
@@ -61,17 +136,32 @@ def send_message_raw_sync(recipient_uuid, text):
url = "http://signal:8080/v2/send"
data = {
"recipients": [recipient_uuid],
"message": text,
"number": settings.SIGNAL_NUMBER,
"base64_attachments": []
}
try:
response = requests.post(url, json=data, timeout=10) # 10s timeout for safety
response.raise_for_status() # Raise an error for non-200 responses
except RequestException as e:
return False # Network or request error
# Convert attachments to Base64
for att in attachments:
base64_data = download_and_encode_base64(att["url"], att["filename"], att["content_type"])
if base64_data:
data["base64_attachments"].append(base64_data)
if response.status_code == status.HTTP_201_CREATED: # Signal server returns 201 on success
# Remove the message body if it only contains an attachment link
if text and (text.strip() in [att["url"] for att in attachments]):
#log.info("Removing message body since it only contains an attachment link.")
text = None # Don't send the link as text
if text:
data["message"] = text
try:
response = requests.post(url, json=data, timeout=10)
response.raise_for_status()
except requests.RequestException as e:
#log.error(f"Failed to send Signal message: {e}")
return False
if response.status_code == 201: # Signal server returns 201 on success
try:
ts = orjson.loads(response.text).get("timestamp")
return ts if ts else False

View File

@@ -9,6 +9,9 @@ from core.clients import signal
from core.lib.prompts.functions import delete_messages
from asgiref.sync import sync_to_async
from django.conf import settings
from core.clients import signalapi
import asyncio
log = logs.get_logger("deferred")
@@ -27,6 +30,7 @@ class DeferredRequest(BaseModel):
msg: Optional[str] = None
service: Optional[str] = None
detail: Optional[DeferredDetail] = None
attachments: Optional[list] = None
async def process_deferred(data: dict, **kwargs):
@@ -60,22 +64,40 @@ async def process_deferred(data: dict, **kwargs):
else:
log.warning(f"Protocol not supported: {message.session.identifier.service}")
return
elif method == "xmpp":
elif method == "xmpp": # send xmpp message
xmpp = kwargs.get("xmpp")
service = validated_data.service
msg = validated_data.msg
attachments = validated_data.attachments
# Get User from identifier
identifiers = PersonIdentifier.objects.filter(
identifier=validated_data.identifier,
service=service,
)
xmpp_attachments = []
# Asynchronously fetch all attachments
tasks = [signalapi.fetch_signal_attachment(att["id"]) for att in attachments]
fetched_attachments = await asyncio.gather(*tasks)
for fetched, att in zip(fetched_attachments, attachments):
if not fetched:
log.warning(f"Failed to fetch attachment {att['id']} from Signal.")
continue
# Attach fetched file to XMPP
xmpp_attachments.append({
"content": fetched["content"],
"content_type": fetched["content_type"],
"filename": fetched["filename"],
"size": fetched["size"],
})
for identifier in identifiers:
# Fair is fair, we can have multiple
log.info(f"Sending {msg} from {identifier}")
xmpp.send_from_external(identifier, msg, validated_data.detail)
#recipient_jid = f"{identifier.user.username}@{settings.XMPP_ADDRESS}"
log.info(f"Sending {len(xmpp_attachments)} attachments from Signal to XMPP.")
await xmpp.send_from_external(identifier, msg, validated_data.detail, attachments=xmpp_attachments)
else:
log.warning(f"Method not yet supported: {method}")
return

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}"
# 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:
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)
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)

View File

@@ -70,6 +70,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",
"method": "xmpp",
@@ -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,

View File

@@ -119,14 +119,15 @@ class PersonIdentifier(models.Model):
def __str__(self):
return f"{self.person} ({self.service})"
def send(self, text):
def send(self, text, attachments=[]):
"""
Send this contact a text.
"""
if self.service == "signal":
ts = signalapi.send_message_raw_sync(
self.identifier,
text
text,
attachments,
)
return ts
else: