307 lines
11 KiB
Python
307 lines
11 KiB
Python
import asyncio
|
|
import base64
|
|
|
|
import aiohttp
|
|
import orjson
|
|
import requests
|
|
from django.conf import settings
|
|
from rest_framework import status
|
|
|
|
|
|
async def start_typing(uuid):
|
|
base = getattr(settings, "SIGNAL_HTTP_URL", "http://signal:8080").rstrip("/")
|
|
url = f"{base}/v1/typing_indicator/{settings.SIGNAL_NUMBER}"
|
|
data = {"recipient": uuid}
|
|
|
|
async with aiohttp.ClientSession() as session:
|
|
async with session.put(url, json=data) as response:
|
|
return await response.text() # Optional: Return response content
|
|
|
|
|
|
async def stop_typing(uuid):
|
|
base = getattr(settings, "SIGNAL_HTTP_URL", "http://signal:8080").rstrip("/")
|
|
url = f"{base}/v1/typing_indicator/{settings.SIGNAL_NUMBER}"
|
|
data = {"recipient": uuid}
|
|
|
|
async with aiohttp.ClientSession() as session:
|
|
async with session.delete(url, json=data) as response:
|
|
return await response.text() # Optional: Return response content
|
|
|
|
|
|
async def download_and_encode_base64(file_url, filename, content_type, session=None):
|
|
"""
|
|
Downloads a file from a given URL asynchronously, 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 | None: The Base64 encoded attachment string in Signal's expected format, or None on failure.
|
|
"""
|
|
try:
|
|
if session is not None:
|
|
async with session.get(file_url, timeout=10) as response:
|
|
if response.status != 200:
|
|
return None
|
|
file_data = await response.read()
|
|
base64_encoded = base64.b64encode(file_data).decode("utf-8")
|
|
return f"data:{content_type};filename={filename};base64,{base64_encoded}"
|
|
|
|
async with aiohttp.ClientSession() as local_session:
|
|
async with local_session.get(file_url, timeout=10) as response:
|
|
if response.status != 200:
|
|
return None
|
|
|
|
file_data = await response.read()
|
|
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 aiohttp.ClientError:
|
|
# log.error(f"Failed to download file: {file_url}, error: {e}")
|
|
return None
|
|
|
|
|
|
async def send_message_raw(recipient_uuid, text=None, attachments=None):
|
|
"""
|
|
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, 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.
|
|
"""
|
|
base = getattr(settings, "SIGNAL_HTTP_URL", "http://signal:8080").rstrip("/")
|
|
url = f"{base}/v2/send"
|
|
|
|
data = {
|
|
"recipients": [recipient_uuid],
|
|
"number": settings.SIGNAL_NUMBER,
|
|
"base64_attachments": [],
|
|
}
|
|
|
|
async def _attachment_to_base64(attachment, session):
|
|
row = dict(attachment or {})
|
|
filename = row.get("filename") or "attachment.bin"
|
|
content_type = row.get("content_type") or "application/octet-stream"
|
|
content = row.get("content")
|
|
if isinstance(content, memoryview):
|
|
content = content.tobytes()
|
|
elif isinstance(content, bytearray):
|
|
content = bytes(content)
|
|
if isinstance(content, bytes):
|
|
encoded = base64.b64encode(content).decode("utf-8")
|
|
return f"data:{content_type};filename={filename};base64,{encoded}"
|
|
file_url = row.get("url")
|
|
if not file_url:
|
|
return None
|
|
return await download_and_encode_base64(file_url, filename, content_type, session)
|
|
|
|
# Asynchronously resolve and encode all attachments
|
|
attachments = attachments or []
|
|
async with aiohttp.ClientSession() as session:
|
|
tasks = [_attachment_to_base64(att, session) for att in attachments]
|
|
encoded_attachments = await asyncio.gather(*tasks)
|
|
|
|
# Filter out failed downloads (None values)
|
|
data["base64_attachments"] = [att for att in encoded_attachments if att]
|
|
|
|
# Remove the message body if it only contains an attachment link
|
|
attachment_urls = {
|
|
str((att or {}).get("url") or "").strip()
|
|
for att in attachments
|
|
if str((att or {}).get("url") or "").strip()
|
|
}
|
|
if text and text.strip() in attachment_urls:
|
|
# 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
|
|
|
|
async with aiohttp.ClientSession() as session:
|
|
async with session.post(url, json=data) as response:
|
|
response_text = await response.text()
|
|
response_status = response.status
|
|
|
|
if response_status == status.HTTP_201_CREATED:
|
|
ts = orjson.loads(response_text).get("timestamp", None)
|
|
return ts if ts else False
|
|
return False
|
|
|
|
|
|
async def send_reaction(
|
|
recipient_uuid,
|
|
emoji,
|
|
target_timestamp=None,
|
|
target_author=None,
|
|
remove=False,
|
|
):
|
|
base = getattr(settings, "SIGNAL_HTTP_URL", "http://signal:8080").rstrip("/")
|
|
sender_number = settings.SIGNAL_NUMBER
|
|
if not recipient_uuid or not target_timestamp:
|
|
return False
|
|
|
|
payload = {
|
|
"recipient": recipient_uuid,
|
|
"reaction": str(emoji or ""),
|
|
"target_author": str(target_author or recipient_uuid),
|
|
"timestamp": int(target_timestamp),
|
|
"remove": bool(remove),
|
|
}
|
|
|
|
candidate_urls = [f"{base}/v1/reactions/{sender_number}"]
|
|
|
|
timeout = aiohttp.ClientTimeout(total=20)
|
|
async with aiohttp.ClientSession(timeout=timeout) as session:
|
|
for url in candidate_urls:
|
|
for method in ("post",):
|
|
try:
|
|
request = getattr(session, method)
|
|
async with request(url, json=payload) as response:
|
|
if 200 <= response.status < 300:
|
|
return True
|
|
except Exception:
|
|
continue
|
|
return False
|
|
|
|
|
|
async def fetch_signal_attachment(attachment_id):
|
|
"""
|
|
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.
|
|
"""
|
|
base = getattr(settings, "SIGNAL_HTTP_URL", "http://signal:8080").rstrip("/")
|
|
url = f"{base}/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_sync(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:
|
|
# log.error(f"Failed to download file: {file_url}, error: {e}")
|
|
return None
|
|
|
|
|
|
def send_message_raw_sync(recipient_uuid, text=None, attachments=None):
|
|
"""
|
|
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, 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.
|
|
"""
|
|
base = getattr(settings, "SIGNAL_HTTP_URL", "http://signal:8080").rstrip("/")
|
|
url = f"{base}/v2/send"
|
|
data = {
|
|
"recipients": [recipient_uuid],
|
|
"number": settings.SIGNAL_NUMBER,
|
|
"base64_attachments": [],
|
|
}
|
|
|
|
attachments = attachments or []
|
|
|
|
# Convert attachments to Base64
|
|
for att in attachments:
|
|
base64_data = download_and_encode_base64_sync(
|
|
att["url"], att["filename"], att["content_type"]
|
|
)
|
|
if base64_data:
|
|
data["base64_attachments"].append(base64_data)
|
|
|
|
# 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:
|
|
# log.error(f"Failed to send Signal message: {e}")
|
|
return False
|
|
|
|
if (
|
|
response.status_code == status.HTTP_201_CREATED
|
|
): # Signal server returns 201 on success
|
|
try:
|
|
ts = orjson.loads(response.text).get("timestamp", None)
|
|
return ts if ts else False
|
|
except orjson.JSONDecodeError:
|
|
return False
|
|
return False # If response status is not 201
|