Files
GIA/core/clients/signalapi.py
2026-03-05 05:42:19 +00:00

419 lines
15 KiB
Python

import asyncio
import base64
import logging
import re
import aiohttp
import orjson
import requests
from django.conf import settings
from rest_framework import status
log = logging.getLogger(__name__)
SIGNAL_UUID_PATTERN = re.compile(
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
re.IGNORECASE,
)
def _safe_parse_send_response(payload_value) -> int | bool:
payload = payload_value
if isinstance(payload_value, str):
try:
payload = orjson.loads(payload_value)
except orjson.JSONDecodeError:
return False
if not isinstance(payload, dict):
return False
try:
ts = payload.get("timestamp")
return int(ts) if ts else False
except (TypeError, ValueError):
return False
def normalize_signal_recipient(recipient: str) -> str:
raw = str(recipient or "").strip()
if not raw:
return ""
if SIGNAL_UUID_PATTERN.fullmatch(raw):
return raw
if raw.startswith("+"):
digits = re.sub(r"[^0-9]", "", raw)
return f"+{digits}" if digits else raw
digits_only = re.sub(r"[^0-9]", "", raw)
if digits_only and raw.isdigit():
return f"+{digits_only}"
return raw
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, metadata=None, detailed=False
):
"""
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"
normalized_recipient = normalize_signal_recipient(recipient_uuid)
data = {
"recipients": [normalized_recipient],
"number": settings.SIGNAL_NUMBER,
"base64_attachments": [],
}
meta = dict(metadata or {})
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
quote_timestamp = int(meta.get("quote_timestamp") or 0)
quote_author = str(meta.get("quote_author") or "").strip()
quote_text = str(meta.get("quote_text") or "").strip()
has_quote = quote_timestamp > 0 and bool(quote_author)
payloads = [dict(data)]
if has_quote:
flat_quote_payload = dict(data)
flat_quote_payload["quote_timestamp"] = int(quote_timestamp)
flat_quote_payload["quote_author"] = quote_author
if quote_text:
flat_quote_payload["quote_message"] = quote_text
nested_quote_payload = dict(data)
nested_quote_payload["quote"] = {
"id": int(quote_timestamp),
"author": quote_author,
}
if quote_text:
nested_quote_payload["quote"]["text"] = quote_text
payloads = [flat_quote_payload, nested_quote_payload, dict(data)]
async with aiohttp.ClientSession() as session:
for index, payload in enumerate(payloads):
async with session.post(url, json=payload) 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
if index == len(payloads) - 1:
log.warning(
"Signal send failed status=%s recipient=%s body=%s",
response_status,
normalized_recipient,
response_text[:300],
)
if detailed:
return {
"ok": False,
"status": int(response_status),
"error": str(response_text or "").strip()[:500],
"recipient": normalized_recipient,
}
return False
if response_status not in {
status.HTTP_400_BAD_REQUEST,
status.HTTP_422_UNPROCESSABLE_ENTITY,
}:
log.warning(
"Signal send failed early status=%s recipient=%s body=%s",
response_status,
normalized_recipient,
response_text[:300],
)
if detailed:
return {
"ok": False,
"status": int(response_status),
"error": str(response_text or "").strip()[:500],
"recipient": normalized_recipient,
}
return False
log.warning(
"signal send quote payload rejected (%s), trying fallback shape: %s",
response_status,
response_text[:200],
)
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
normalized_recipient = normalize_signal_recipient(recipient_uuid)
normalized_target_author = normalize_signal_recipient(
str(target_author or normalized_recipient)
)
if not normalized_recipient or not target_timestamp:
return False
payload = {
"recipient": normalized_recipient,
"reaction": str(emoji or ""),
"target_author": normalized_target_author,
"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": [normalize_signal_recipient(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:
payload = response.json()
except ValueError:
payload = {}
return _safe_parse_send_response(payload)
return False # If response status is not 201