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 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 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": , "content_type": , "filename": } 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: 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