#!/usr/bin/env python3 """ sendmsg — Universal message sender for Signal and SMS/iMessage. Usage: sendmsg --signal --to +18005551212 --text "Hello from Signal!" sendmsg --signal --recipients group_id_here --text "All members" sendmsg --sms --to +18005551212 --text "Hello via SMS!" --service sms sendmsg --sms --to +18005551212 --text "Hey" --service imessage --file ~/photo.png sendmsg --signal --to +18005551212 --text "Multi" --text "more text" sendmsg --signal --to +18005551212 --attach ~/doc.pdf ~/pic.jpg sendmsg --signal --to +18005551212 --voice ~/note.m4a # Send a voice message sendmsg --csv messages.csv # Bulk send from CSV file sendmsg --csv messages.csv --delay 2 # Wait N seconds between sends sendmsg --csv messages.csv --dry-run # Preview without sending sendmsg --list-signal # Show linked Signal accounts sendmsg --link-signal # Link a new device to your existing Signal account CSV format (messages.csv): method,recipient,name,message,account,service,file,voice,delay signal,+1888222333,Alice,Hello via Signal,+1888222333,,,, signal,group.ZzBHd3NZO...,Group alert,Hi team,,,,, sms,+1234567890,Bob,SMS test,,imessage,~/pic.jpg,, signal,+1888222333,Carol,With delay,,,,,3 """ import argparse import base64 import configparser import csv import json import mimetypes import os import shutil import subprocess import sys import time import urllib.error import urllib.parse import urllib.request VERSION = "5.1.1" CONFIG_PATHS = [ os.path.expanduser("~/.sendmsg.conf"), ] # Maximum attachment size accepted before base64-encoding into the JSON body. # Large files balloon memory and request size; the REST API also rejects them. MAX_ATTACHMENT_BYTES = 100 * 1024 * 1024 # 100 MB # Number of rows in a CSV batch above which we ask for confirmation, # unless --yes is supplied. CSV_CONFIRM_THRESHOLD = 50 # Audio MIME types that signal-cli will accept as a voice note. VOICE_AUDIO_EXTS = {".m4a", ".aac", ".ogg", ".opus", ".mp3", ".wav"} def load_config(): """Load settings from sendmsg config file, with env var fallback. Config file format (INI-style): [settings] signal_rest_url = http://localhost:8080 signal_default_account = +1234567890 Lookup order: env var > config file > hardcoded default. """ defaults = { "signal_rest_url": "http://localhost:8080", "signal_default_account": "+1234567890", } env_vars = { "signal_rest_url": "SIGNAL_REST_URL", "signal_default_account": "SIGNAL_ACCOUNT", } config = configparser.ConfigParser() config_path = None for path in CONFIG_PATHS: if os.path.isfile(path): config.read(path) config_path = path break resolved = {} sources = {} for key, default in defaults.items(): env_val = os.environ.get(env_vars[key]) if env_val: resolved[key] = env_val sources[key] = f"env (${env_vars[key]})" elif config.has_option("settings", key): resolved[key] = config.get("settings", key) sources[key] = f"config ({config_path})" else: resolved[key] = default sources[key] = "default" resolved["_sources"] = sources resolved["_config_path"] = config_path return resolved # Load config at module level for use by all functions CFG = load_config() SIGNAL_REST_URL = CFG["signal_rest_url"] SIGNAL_DEFAULT_ACCOUNT = CFG["signal_default_account"] # The built-in default account is a placeholder; sending from it will fail at # the Signal server. Track whether the resolved account is still the default # so we can warn before attempting a real send. PLACEHOLDER_ACCOUNT = "+1234567890" if not (SIGNAL_REST_URL.startswith("http://") or SIGNAL_REST_URL.startswith("https://")): print( f"⚠️ Warning: signal_rest_url '{SIGNAL_REST_URL}' does not look like a " f"valid URL (expected http:// or https://).", file=sys.stderr, ) # --------------------------------------------------------------------------- # Helpers — reusable without argparse objects (needed for CSV dispatch) # --------------------------------------------------------------------------- def expand_path(path): """Expand '~' and environment variables in a file path. CSV rows supply literal paths like '~/pic.jpg'; unlike the shell, Python does not expand the tilde, so os.path.isfile('~/pic.jpg') is always False. This caused CSV Signal attachments to be silently dropped. """ if not path: return path return os.path.expanduser(os.path.expandvars(path.strip())) def normalize_account(account): """Ensure an account/phone number carries a leading '+'. Group IDs (starting with 'group.') are returned unchanged. """ if not account: return account account = account.strip() if account.startswith("group."): return account if not account.startswith("+"): account = "+" + account return account def looks_like_phone_number(value): """Return True if value looks like a phone number. Accepts numbers with an explicit '+', and also bare all-digit strings (which a user may type without the leading '+'). This is the inverse test used by is_group_id, and the two must agree on what a bare number is so a phone number is never mis-routed as a group. """ if not value: return False value = value.strip() if value.startswith("+"): return value[1:].isdigit() and len(value) > 1 # A bare run of digits (optionally long) is a phone number, not a group. return value.isdigit() def is_group_id(value): """Return True if a recipient value looks like a Signal group ID. signal-cli group IDs are either the 'group.' form used by the REST API, or a raw base64 internal group key. Sending a group message down the 1:1 'recipient' path is what causes the 'Unregistered user ' errors and the wrong-sender appearance in issue #3, so we treat anything that is not a phone number as a group ID. """ if not value: return False value = value.strip() if value.startswith("group."): return True # Anything that is recognisably a phone number is NOT a group. This now # correctly handles bare (no '+') phone numbers, which previously fell # through and were mis-detected as group IDs. if looks_like_phone_number(value): return False return True def normalize_recipient(value): """Normalize a recipient for the Signal recipients[] array. Phone numbers get a leading '+'; group IDs are passed through unchanged. """ if not value: return value value = value.strip() if is_group_id(value): return value return normalize_account(value) def validate_attachment(path, *, as_voice=False): """Validate an attachment path. Returns the expanded path if valid, otherwise None (with a warning). """ if not path: return None path = expand_path(path) if not os.path.isfile(path): print(f"⚠️ Warning: attachment not found: {path}", file=sys.stderr) return None try: size = os.path.getsize(path) except OSError as e: print(f"⚠️ Warning: cannot read attachment {path}: {e}", file=sys.stderr) return None if size > MAX_ATTACHMENT_BYTES: print( f"⚠️ Warning: attachment too large " f"({size / 1024 / 1024:.1f} MB > " f"{MAX_ATTACHMENT_BYTES / 1024 / 1024:.0f} MB limit): {path}", file=sys.stderr, ) return None if as_voice: ext = os.path.splitext(path)[1].lower() if ext not in VOICE_AUDIO_EXTS: print( f"⚠️ Warning: '{ext}' is not a recognized voice/audio format " f"(expected one of {', '.join(sorted(VOICE_AUDIO_EXTS))}); " f"sending anyway.", file=sys.stderr, ) return path def send_one_signal(account, recipient, message, group_id=None, attach=None, voice=None): """Send a single Signal message. Returns True on success. `attach` may be a single path or a list of paths. `voice` is a single audio path to be delivered as a voice note. """ account = normalize_account(account) if account == PLACEHOLDER_ACCOUNT: print( f"⚠️ Warning: sending from the placeholder account {PLACEHOLDER_ACCOUNT}. " f"Set SIGNAL_ACCOUNT or signal_default_account in ~/.sendmsg.conf, " f"or pass --account.", file=sys.stderr, ) target = group_id if group_id else normalize_recipient(recipient) payload = {"number": account, "recipients": [target]} if message: payload["message"] = message # Normalize attach to a list of valid paths. attach_list = [] if attach: candidates = attach if isinstance(attach, (list, tuple)) else [attach] for c in candidates: valid = validate_attachment(c) if valid: attach_list.append(valid) voice_path = validate_attachment(voice, as_voice=True) if voice else None has_attachment = bool(attach_list) has_voice = bool(voice_path) if not message and not has_attachment and not has_voice: print( f"❌ Signal send skipped: no message text, attachment, or voice note " f"(recipient: {group_id or recipient})", file=sys.stderr, ) return False # A voice note is sent as its own message (a voice note cannot be mixed # with arbitrary file attachments in a single send). overall_ok = True if has_voice: voice_payload = {"number": account, "recipients": [target], "voice": True} if message and not has_attachment: # Attach the text to the voice message if there are no other files. voice_payload["message"] = message vres = signal_rest_post("/v2/send", payload=voice_payload, files=[voice_path]) overall_ok = overall_ok and (vres is not None) # If text rode along with the voice note, don't send it again below. if has_attachment and message: pass # text will be sent with the attachments below else: message = None # consumed if has_attachment: files_payload = {"number": account, "recipients": [target]} if message: files_payload["message"] = message ares = signal_rest_post("/v2/send", payload=files_payload, files=attach_list) overall_ok = overall_ok and (ares is not None) elif message and not has_voice: # Pure text-only send. tres = signal_rest_post("/v2/send", payload=payload) overall_ok = overall_ok and (tres is not None) return overall_ok def send_one_sms(to, message, service, file=None): """Send a single SMS/iMessage. Returns True on success. `service` must be 'sms' or 'imessage' (required — no auto-detect). """ cmd = ["imsg", "send", "--to", to, "--service", service] cmd.extend(["--text", message]) if file: file = expand_path(file) if os.path.isfile(file): cmd.extend(["--file", file]) else: print(f"⚠️ Warning: file not found, skipping attachment: {file}", file=sys.stderr) if not shutil.which("imsg"): print("❌ SMS send failed: 'imsg' CLI not found on PATH.", file=sys.stderr) return False # imsg can hang if the Messages app is unresponsive; bound each attempt # with a timeout and retry a couple of times on transient failures. max_attempts = 3 backoff = 2.0 for attempt in range(1, max_attempts + 1): try: result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) except subprocess.TimeoutExpired: if attempt < max_attempts: wait = backoff * attempt print( f"⚠️ SMS send to {to} timed out; retrying in {wait:.0f}s " f"(attempt {attempt}/{max_attempts})...", file=sys.stderr, ) time.sleep(wait) continue print(f"❌ SMS send failed: 'imsg' timed out after {max_attempts} attempts.", file=sys.stderr) return False except OSError as e: print(f"❌ SMS send failed: could not run 'imsg': {e}", file=sys.stderr) return False if result.returncode == 0: print(f"✅ SMS/iMessage sent to {to} (service: {service.upper()})") return True if attempt < max_attempts: wait = backoff * attempt print( f"⚠️ SMS send to {to} failed (exit {result.returncode}); retrying in " f"{wait:.0f}s (attempt {attempt}/{max_attempts})...", file=sys.stderr, ) time.sleep(wait) continue print(f"❌ SMS send failed (exit {result.returncode}): {result.stderr.strip()}", file=sys.stderr) return False return False # --------------------------------------------------------------------------- # REST API # --------------------------------------------------------------------------- def signal_rest_post(endpoint, payload=None, files=None): """Post a JSON request to the signal-cli-rest-api. The /v2/send endpoint expects a single JSON body. Attachments are NOT sent as multipart/form-data — they must be base64-encoded and placed in the "base64_attachments" array, each as a data URI of the form 'data:;filename=;base64,'. Sending multipart produces HTTP 400 "invalid request". Returns the parsed JSON response dict, or None on failure. """ url = f"{SIGNAL_REST_URL.rstrip('/')}{endpoint}" payload = dict(payload) if payload else {} if files: b64_attachments = [] for fpath in files: fpath = expand_path(fpath) if not os.path.isfile(fpath): print(f"⚠️ Warning: attachment not found: {fpath}", file=sys.stderr) continue with open(fpath, "rb") as fh: encoded = base64.b64encode(fh.read()).decode("ascii") fname = os.path.basename(fpath) mime = mimetypes.guess_type(fpath)[0] or "application/octet-stream" # Data-URI form preserves both MIME type and filename. b64_attachments.append(f"data:{mime};filename={fname};base64,{encoded}") if b64_attachments: payload["base64_attachments"] = b64_attachments body = json.dumps(payload).encode("utf-8") headers = {"Content-Type": "application/json"} # Retry transient failures (network blips, 429, 5xx) with backoff. max_attempts = 3 backoff = 2.0 for attempt in range(1, max_attempts + 1): req = urllib.request.Request(url, data=body, headers=headers, method="POST") try: with urllib.request.urlopen(req, timeout=30) as resp: resp_data = resp.read().decode("utf-8") if resp_data.strip(): result = json.loads(resp_data) if isinstance(result, dict) and "error" in result: print( f"❌ Signal REST error: {result['error'].strip()}", file=sys.stderr, ) return None return result return {} except urllib.error.HTTPError as e: err_body = e.read().decode("utf-8", errors="replace") # Retry on rate-limit / server errors; fail fast otherwise. if e.code in (429, 500, 502, 503, 504) and attempt < max_attempts: wait = backoff * attempt print( f"⚠️ Signal REST HTTP {e.code}; retrying in {wait:.0f}s " f"(attempt {attempt}/{max_attempts})...", file=sys.stderr, ) time.sleep(wait) continue print( f"❌ Signal REST error: HTTP {e.code} — {err_body[:500]}", file=sys.stderr, ) return None except urllib.error.URLError as e: if attempt < max_attempts: wait = backoff * attempt print( f"⚠️ Cannot reach Signal REST API ({e.reason}); retrying in " f"{wait:.0f}s (attempt {attempt}/{max_attempts})...", file=sys.stderr, ) time.sleep(wait) continue print(f"❌ Cannot reach Signal REST API at {SIGNAL_REST_URL}: {e.reason}", file=sys.stderr) return None return None def signal_rest_get(endpoint): """GET a JSON response from the signal-cli-rest-api.""" url = f"{SIGNAL_REST_URL.rstrip('/')}{endpoint}" req = urllib.request.Request(url, method="GET") try: with urllib.request.urlopen(req, timeout=15) as resp: data = resp.read().decode("utf-8") if data.strip(): return json.loads(data) return None except urllib.error.HTTPError as e: body = e.read().decode("utf-8", errors="replace") print(f"❌ Signal REST error: HTTP {e.code} — {body[:500]}", file=sys.stderr) return None except urllib.error.URLError as e: print(f"❌ Cannot reach Signal REST API at {SIGNAL_REST_URL}: {e.reason}", file=sys.stderr) return None # --------------------------------------------------------------------------- # Command functions # --------------------------------------------------------------------------- def cmd_signal(args): """Send via Signal using the remote REST API.""" # --to is only required for direct messages (not group sends) if not args.to and not args.recipients: print("❌ Error: --to is required for direct messages, or use --recipients for groups.", file=sys.stderr) sys.exit(1) attach_files = getattr(args, "attach", None) or getattr(args, "file", None) voice_file = getattr(args, "voice", None) # Text, an attachment, or a voice note is required in ALL cases. if not args.text and not attach_files and not voice_file: print("❌ Error: --text , --attach , or --voice