From e23f96b1400455b6e2eef9006296a1ed589d10dd Mon Sep 17 00:00:00 2001 From: Daniel Spisak Date: Mon, 8 Jun 2026 03:41:28 -0700 Subject: [PATCH] commit first release files and docs --- .gitignore | 25 +++ README.md | 318 ++++++++++++++++++++++++++- sendmsg | 616 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 958 insertions(+), 1 deletion(-) create mode 100755 sendmsg diff --git a/.gitignore b/.gitignore index 83972fa..6649be1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,28 @@ +MacOS garbage to ignore +# Folder view configuration files +.DS_Store +.localized + +# Thumbnail cache files +._* + +# Files that might appear on external disks / network shares +.Spotlight-V100 +.Trashes +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# Directory metadata +__MACOSX/ +.AppleDouble +.LSOverride + +# Icon files +Icon[] + # Byte-compiled / optimized / DLL files __pycache__/ *.py[codz] diff --git a/README.md b/README.md index beedf30..9fa5be8 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,318 @@ # sendmsg -Universal message sender for Signal and SMS/iMessage + +**Universal message sender for Signal and SMS/iMessage.** + +`sendmsg` is a command-line tool for sending messages via the [Signal REST API](https://github.com/bbernhard/signal-cli-rest-api) or locally via macOS [Messages](https://apps.apple.com/app/messages/id1092291483) (iMessage/SMS) through the [`imsg`](https://github.com/DocWho76/imsg) CLI. + +It supports individual direct messages, Signal group broadcasts, file attachments, and bulk sending from CSV files — all with a single, consistent interface. + +--- + +## Table of Contents + +- [Architecture](#architecture) +- [Dependencies](#dependencies) +- [Installation](#installation) +- [Usage](#usage) + - [Signal Messages](#signal-messages) + - [SMS / iMessage](#sms--imessage) + - [Bulk Send from CSV](#bulk-send-from-csv) + - [Management Commands](#management-commands) +- [CSV Format](#csv-format) +- [Configuration](#configuration) +- [Examples](#examples) + +--- + +## Architecture + +``` +┌─────────────────────────────────────────────────────────┐ +│ sendmsg (CLI) │ +│ Python 3 script with argparse-based argument parsing │ +├─────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────┐ ┌──────────────────────────┐ │ +│ │ --signal │ │ --sms │ │ +│ │ │ │ │ │ +│ │ POST to │ │ Call `imsg send` CLI │ │ +│ │ Signal REST │ │ (macOS Messages) │ │ +│ │ API │ │ │ │ +│ └──────┬───────┘ └──────────┬───────────────┘ │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ signal-cli-rest-api (Docker) │ │ +│ │ http://localhost:8080 (or SIGNAL_REST_URL) │ │ +│ └──────────────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ Docker Container │ │ +│ │ signal-cli-rest-api │ │ +│ └──────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────┘ +``` + +**Signal path:** `sendmsg` → HTTP POST → `signal-cli-rest-api` → Docker container → Signal network. + +**SMS/iMessage path:** `sendmsg` → subprocess call → `imsg` CLI → macOS Messages framework → carrier/Apple. + +--- + +## Dependencies + +### Required + +| Dependency | Version | Purpose | +|---|---|---| +| Python 3 | 3.9+ | Script runtime | +| macOS 12+ | Monterey or later | Required for iMessage/SMS support | + +### Signal (optional — only needed for `--signal`) + +| Dependency | Version | Purpose | +|---|---|---| +| Docker | 24+ (Colima on Apple Silicon) | Container runtime | +| `signal-cli-rest-api` | latest | Signal REST API server | +| `signal-cli` (via container) | latest | Signal protocol implementation | + +### SMS/iMessage (optional — only needed for `--sms`) + +| Dependency | Version | Purpose | +|---|---|---| +| `imsg` CLI | latest | macOS Messages / SMS bridge | + +### System + +- **macOS** — The script is designed for macOS; SMS/iMessage uses the native Messages app. +- **Network access** — Required to reach the Signal REST API endpoint. + +--- + +## Installation + +1. **Place the script:** + + ```bash + cp sendmsg /usr/local/bin/sendmsg + chmod +x /usr/local/bin/sendmsg + ``` + +2. **Ensure dependencies are installed:** + + **For Signal:** + ```bash + # Verify signal-cli-rest-api is running + curl -s http://localhost:8080/v1/accounts | python3 -m json.tool + ``` + + **For SMS/iMessage:** + ```bash + # Verify imsg is installed and available + imsg send --help + ``` + +3. **Test the script:** + + ```bash + sendmsg --help + ``` + +--- + +## Usage + +``` +sendmsg [OPTIONS] + +Messaging methods (choose one): + --signal Send via Signal + --sms Send via SMS/iMessage (macOS Messages) + --csv FILE Bulk send from a CSV file + +Management commands (choose one): + --list-signal List linked Signal accounts + --link-signal Link a new device to your Signal account +``` + +### Signal Messages + +```bash +# Send a text message +sendmsg --signal --to +18885551212 --text "Hello from Signal!" + +# Send to a group +sendmsg --signal --recipients group.TestGroupHash --text "Hello everyone!" + +# Send with an attachment +sendmsg --signal --to +18885551212 --text "Check this out" --attach ~/photo.jpg + +# Send multiple messages (concatenated with newlines) +sendmsg --signal --to +18885551212 --text "Line 1" --text "Line 2" +``` + +### SMS / iMessage + +```bash +# Send via iMessage (auto-detected) +sendmsg --sms --to +18885551212 --text "Hello via iMessage!" + +# Force SMS service +sendmsg --sms --to +18885551212 --text "Hello via SMS!" --service sms + +# Send with an attachment +sendmsg --sms --to +18885551212 --text "Photo attached" --file ~/photo.jpg +``` + +### Bulk Send from CSV + +```bash +sendmsg --csv messages.csv # Send all rows +sendmsg --csv messages.csv --delay 2 # Wait 2 seconds between sends +``` + +### Management Commands + +```bash +# List linked Signal accounts +sendmsg --list-signal + +# Link a new device +sendmsg --link-signal +sendmsg --link-signal --name "my-laptop" +``` + +--- + +## CSV Format + +Create a CSV file with the following columns: + +| Column | Required | Description | +|---|---|---| +| `method` | Yes | `signal` or `sms` | +| `recipient` | Yes | Phone number, group ID (for Signal), or empty for group sends | +| `name` | No | Display name shown during status output (e.g., "Alice", "Marketing Group") | +| `message` | Yes | The message text to send | +| `account` | No | Signal account phone number (defaults to `+156****1603` or `$SIGNAL_ACCOUNT`) | +| `service` | No | SMS service: `imessage`, `sms`, or `auto` (SMS only) | +| `file` | No | Path to an attachment file (relative or absolute) | +| `delay` | No | Seconds to wait after this row is sent (e.g., `3`) | + +### Example CSV + +```csv +method,recipient,name,message,account,service,file,delay +signal,+156****1603,Alice,Hello via Signal,+156****1603,,, +signal,group.ZzBHd3NZO...,Team Alert,,,,-1 +sms,+123****7890,Bob,SMS test,,,~/pic.jpg, +signal,+156****1603,Carol,With delay,,,-3 +``` + +### Status Output + +During a bulk send, each row prints its name (if provided): + +``` +[1/4] SIGNAL → Alice (+156****1603) +[2/4] SIGNAL → Team Alert (group.ZzBHd3NZO...) +[3/4] SMS → Bob (+123****7890) +[4/4] SIGNAL → Carol (+156****1603) +``` + +If no `name` is provided, the recipient is shown instead. + +--- + +## Configuration + +### Load settings from sendmsg config file in ~/.sendmsg.conf, with env var fallback. + + Config file format (INI-style): + [settings] + signal_rest_url = http://localhost:8080 + signal_default_account = +1234567890 + +### Environment Variables + +| Variable | Default | Description | +|---|---|---| +| `SIGNAL_REST_URL` | `http://localhost:8080` | URL of the Signal REST API server | +| `SIGNAL_ACCOUNT` | `+1234567890` | Default Signal account phone number | + +### Override Example + +```bash +export SIGNAL_REST_URL=http://localhost:8082 +export SIGNAL_ACCOUNT=+18885551212 +sendmsg --csv messages.csv +``` + +--- + +## Examples + +### Daily Broadcast + +```csv +method,recipient,name,message,account,service,file,delay +signal,group.ZzBHd3NZO...,Daily Update,Good morning team! Here's your daily briefing.,+18002222222,,, +``` + +### Personalized Outreach + +```csv +method,recipient,name,message,account,service,file,delay +signal,+18885551111,John,Hi John, hope you're doing well! +sms,+18885552222,Jane,Hey Jane, just checking in! +signal,+18885553333,Alex,Alex, don't forget about the meeting tomorrow at 3pm! +``` + +### With Attachments + +```csv +method,recipient,name,message,account,service,file,delay +signal,+18885551111,John,Here's the report you asked for,+18002222222,,,~/Downloads/report.pdf +sms,+18885552222,Jane,Photo from the event,,,~/Photos/event.jpg, +``` + +### With Delays + +```csv +method,recipient,name,message,account,service,file,delay +signal,+18885551111,Alice,First message,,, +signal,+18885552222,Bob,Second message,,,3 +signal,+18885553333,Charlie,Third message,,, +``` + +--- + +## Error Handling + +- **Unknown method:** Rows with invalid `method` values are skipped and reported in the summary. +- **Empty message:** Rows without a `message` are skipped and reported. +- **Failed sends:** Failed attempts are counted and listed in the summary. +- **Exit codes:** The script exits with `1` if any rows fail, `0` on full success. + +--- + +## Summary Output + +After processing all rows, a summary is printed: + +``` +================================================== +📊 CSV Send Summary +================================================== + Total rows: 4 + ✅ Success: 3 + ❌ Failed: 1 + + Errors: + • Row 2: failed to send +================================================== +``` + +--- + +*For support or issues, see https://xkcd.com/627/* diff --git a/sendmsg b/sendmsg new file mode 100755 index 0000000..aaf2330 --- /dev/null +++ b/sendmsg @@ -0,0 +1,616 @@ +#!/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!" + sendmsg --sms --to +18005551212 --text "Hey" --file ~/photo.png + sendmsg --signal --to +18005551212 --text "Multi" --text "more text" + sendmsg --signal --to +18005551212 --attach ~/doc.pdf ~/pic.jpg + sendmsg --csv messages.csv # Bulk send from CSV file + sendmsg --csv messages.csv --delay 2 # Wait N seconds between sends + 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,delay + signal,+1888222333,Alice,Hello via Signal,+1888222333,,, + signal,group.ZzBHd3NZO...,Group alert,,,,-1 + sms,+123****7890,Bob,SMS test,,,~/pic.jpg, + signal,+1888222333,Carol,With delay,,,-3 +""" + +import argparse +import configparser +import csv +import json +import os +import subprocess +import sys +import time +import urllib.error +import urllib.parse +import urllib.request + +VERSION = "4.0.0" +CONFIG_PATHS = [ + os.path.expanduser("~/.sendmsg.conf"), +] + + +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", + } + config = configparser.ConfigParser() + + for path in CONFIG_PATHS: + if os.path.isfile(path): + config.read(path) + break + + return { + "signal_rest_url": ( + os.environ.get("SIGNAL_REST_URL") + or config.get("settings", "signal_rest_url", fallback=defaults["signal_rest_url"]) + ), + "signal_default_account": ( + os.environ.get("SIGNAL_ACCOUNT") + or config.get("settings", "signal_default_account", fallback=defaults["signal_default_account"]) + ), + } + + +# 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"] + + +# --------------------------------------------------------------------------- +# Helpers — reusable without argparse objects (needed for CSV dispatch) +# --------------------------------------------------------------------------- + +def send_one_signal(account, recipient, message, group_id=None, attach=None): + """Send a single Signal message. Returns True on success.""" + payload = {"number": account} + + if group_id: + payload["recipients"] = [group_id] + else: + payload["recipient"] = recipient + + if message: + payload["message"] = message + + # Attachments + if attach and os.path.isfile(attach): + result = signal_rest_post("/v2/send", payload=payload, files=[attach]) + elif message and not group_id: + result = signal_rest_post("/v2/send", payload=payload) + else: + result = signal_rest_post("/v2/send", payload=payload) + + return result is not None + + +def send_one_sms(to, message, service=None, file=None): + """Send a single SMS/iMessage. Returns True on success.""" + cmd = ["imsg", "send", "--to", to] + + if service: + cmd.extend(["--service", service]) + + cmd.extend(["--text", message]) + + if file and os.path.isfile(file): + cmd.extend(["--file", file]) + + result = subprocess.run(cmd, capture_output=True, text=True) + + if result.returncode == 0: + service_label = service.upper() if service else "AUTO" + print(f"✅ SMS/iMessage sent to {to} (service: {service_label})") + return True + else: + print(f"❌ SMS send failed (exit {result.returncode}): {result.stderr.strip()}", file=sys.stderr) + return False + + +# --------------------------------------------------------------------------- +# REST API +# --------------------------------------------------------------------------- + +def signal_rest_post(endpoint, payload=None, files=None): + """Post JSON or multipart form data to the signal-cli-rest-api. + + Returns the parsed JSON response dict, or None on failure. + """ + url = f"{SIGNAL_REST_URL.rstrip('/')}{endpoint}" + + if files is not None and files: + # Multipart form data for attachments + boundary = "----sendmsg-boundary" + body_parts = [] + + if payload: + for key, value in payload.items(): + body_parts.append( + f"--{boundary}\r\n" + f'Content-Disposition: form-data; name="{key}"\r\n\r\n' + f"{value}\r\n" + ) + + for fpath in files: + if not os.path.isfile(fpath): + print(f"⚠️ Warning: attachment not found: {fpath}", file=sys.stderr) + continue + with open(fpath, "rb") as fh: + content = fh.read() + fname = os.path.basename(fpath) + body_parts.append( + f"--{boundary}\r\n" + f"Content-Disposition: form-data; name=\"attachment\"; filename=\"{fname}\"\r\n" + f"Content-Type: application/octet-stream\r\n\r\n" + ) + body_parts.append(content) + body_parts.append(b"\r\n") + + body_parts.append(f"--{boundary}--\r\n".encode("utf-8")) + full_body = b"".join(body_parts) + + headers = { + "Content-Type": f"multipart/form-data; boundary={boundary}", + } + else: + # JSON payload + body = json.dumps(payload).encode("utf-8") if payload else b"{}" + headers = {"Content-Type": "application/json"} + full_body = body + + req = urllib.request.Request(url, data=full_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 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 + + +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) + if not args.text and not getattr(args, "attach", None) and not args.recipients: + print("❌ Error: --text or --attach is required.", file=sys.stderr) + sys.exit(1) + + account = args.account or SIGNAL_DEFAULT_ACCOUNT + if not account.startswith("+"): + account = "+" + account + + # Build the JSON payload for /v2/send + payload = {"number": account} + + if args.recipients: + # Send to a Signal group — use recipients array with group. prefix + payload["recipients"] = [args.recipients] + else: + # Send to individual recipients (string) + payload["recipient"] = args.to[0] + + # Message text (join multiple --text args) + if args.text: + payload["message"] = "\n".join(args.text) + + # Attachments + attach_files = getattr(args, "attach", None) + + if attach_files and len(attach_files) > 0: + if len(attach_files) == 1: + # Single file — send as multipart with message + result = signal_rest_post("/v2/send", payload=payload, files=attach_files) + else: + # Multiple files: send message first, then each attachment separately + multi_payload = {"number": account, "message": payload.get("message", "")} + if args.recipients: + multi_payload["recipients"] = [args.recipients] + else: + multi_payload["recipient"] = args.to[0] + result = signal_rest_post("/v2/send", payload=multi_payload) + if result is None: + sys.exit(1) + # Then send each attachment + for fpath in attach_files: + att_payload = {"number": account} + if args.recipients: + att_payload["recipients"] = [args.recipients] + else: + att_payload["recipient"] = args.to[0] + result = signal_rest_post("/v2/send", payload=att_payload, files=[fpath]) + if result is None: + print(f"⚠️ Attachment failed: {fpath}", file=sys.stderr) + elif args.text and not args.recipients: + result = signal_rest_post("/v2/send", payload=payload) + else: + # Group message or other case + result = signal_rest_post("/v2/send", payload=payload) + + if result is not None: + if args.recipients: + print(f"✅ Signal message sent to group {args.recipients}") + elif args.to: + print(f"✅ Signal message sent to {args.to[0]}") + else: + print("✅ Signal message sent") + if args.verbose and result: + # Print any extra details from the API response + for key, value in result.items(): + if key not in ("success",): + print(f" {key}: {value}") + else: + print("❌ Signal message send failed.", file=sys.stderr) + sys.exit(1) + + +def cmd_sms(args): + """Send via SMS/iMessage using imsg.""" + if not args.to: + print("❌ Error: --to is required for SMS.", file=sys.stderr) + sys.exit(1) + if not args.text: + print("❌ Error: --text is required for SMS.", file=sys.stderr) + sys.exit(1) + + cmd = ["imsg", "send", "--to", args.to[0]] + + if args.service: + cmd.extend(["--service", args.service]) + + msg_text = "\n".join(args.text) + cmd.extend(["--text", msg_text]) + + if getattr(args, "file", None): + for f in args.file: + if not os.path.isfile(f): + print(f"⚠️ Warning: file not found: {f}", file=sys.stderr) + cmd.extend(["--file", f]) + + result = subprocess.run(cmd, capture_output=True, text=True) + + if result.returncode == 0: + service_label = args.service.upper() if args.service else "AUTO" + print(f"✅ SMS/iMessage sent to {args.to[0]} (service: {service_label})") + else: + print(f"❌ SMS send failed (exit {result.returncode})", file=sys.stderr) + if result.stderr: + print(f" {result.stderr.strip()}", file=sys.stderr) + + +def cmd_csv(args): + """Bulk send messages from a CSV file. + + Expected CSV columns: + method, recipient, name, message, account, service, file, delay + """ + filepath = args.csv + + if not os.path.isfile(filepath): + print(f"❌ Error: file not found: {filepath}", file=sys.stderr) + sys.exit(1) + + delay = float(args.delay) if args.delay else 0.0 + + with open(filepath, newline="", encoding="utf-8") as csvfile: + reader = csv.DictReader(csvfile) + rows = list(reader) + + if not rows: + print("❌ Error: CSV file is empty (no data rows).", file=sys.stderr) + sys.exit(1) + + total = len(rows) + success = 0 + failed = 0 + errors = [] + + for i, row in enumerate(rows, 1): + method = row.get("method", "").strip().lower() + message = row.get("message", "").strip() + to = row.get("recipient", "").strip() + name = row.get("name", "").strip() + + # Skip blank rows + if not method and not message: + continue + + if not method: + method = "signal" # default + + if method not in ("signal", "sms"): + print(f"⚠️ Row {i}: unknown method '{method}' — skipping", file=sys.stderr) + failed += 1 + errors.append(f"Row {i}: unknown method '{method}'") + continue + + if not message: + print(f"⚠️ Row {i}: empty message — skipping", file=sys.stderr) + failed += 1 + errors.append(f"Row {i}: empty message") + continue + + # Common fields + account = row.get("account", "").strip() or SIGNAL_DEFAULT_ACCOUNT + service = row.get("service", "").strip() or None + file_path = row.get("file", "").strip() or None + delay_str = row.get("delay", "0").strip() + row_delay = float(delay_str) if delay_str else 0.0 + + label = f"{name} ({to})" if name else to or "(group)" + print(f"[{i}/{total}] {method.upper()} → {label}") + + if method == "signal": + group_id = to if to.startswith("group.") else None + result = send_one_signal( + account=account, + recipient=to, + message=message, + group_id=group_id, + attach=file_path, + ) + elif method == "sms": + result = send_one_sms( + to=to, + message=message, + service=service, + file=file_path, + ) + if result: + success += 1 + else: + failed += 1 + errors.append(f"Row {i}: SMS failed") + if row_delay > 0: + time.sleep(row_delay) + continue + + if result: + success += 1 + else: + failed += 1 + errors.append(f"Row {i}: failed to send") + + # Delay between sends (only if explicitly set in row) + if row_delay > 0: + time.sleep(row_delay) + + # Summary + print() + print("=" * 50) + print("📊 CSV Send Summary") + print("=" * 50) + print(f" Total rows: {total}") + print(f" ✅ Success: {success}") + print(f" ❌ Failed: {failed}") + if errors: + print() + print(" Errors:") + for e in errors: + print(f" • {e}") + print("=" * 50) + + if failed > 0: + sys.exit(1) + + +def cmd_list_signal(args): + """List linked Signal accounts via the REST API.""" + result = signal_rest_get("/v1/accounts") + if result is None: + print(f"❌ Failed to list accounts (cannot reach {SIGNAL_REST_URL})", file=sys.stderr) + sys.exit(1) + + if isinstance(result, list): + accounts = [str(a) for a in result] + elif isinstance(result, dict) and "accounts" in result: + accounts = result["accounts"] + elif isinstance(result, dict) and "number" in result: + accounts = [result["number"]] + else: + accounts = [str(result)] + + if not accounts: + print("No Signal accounts linked. Use --link-signal to set one up.") + else: + print("Linked Signal accounts:") + for acct in accounts: + print(f" • {acct}") + + +def cmd_link_signal(args): + """Link a new Signal device using the sgnl:// URL method. + + The signal-cli-rest-api supports device linking via its /v1/link endpoint. + This prints the sgnl:// URL to stdout, then waits for the user to scan/confirm. + """ + name = args.name or "automation" + payload = {"name": name} + result = signal_rest_post("/v1/link", payload=payload) + + if result is None: + print("❌ Failed to initiate device linking.", file=sys.stderr) + sys.exit(1) + + sgnl_url = result.get("url", result.get("linkUrl", "")) + if not sgnl_url: + print("❌ No sgnl:// URL returned. API response:", file=sys.stderr) + print(json.dumps(result, indent=2), file=sys.stderr) + sys.exit(1) + + print(f"🔗 Scan this QR code or visit this URL to link your device:") + print(f" {sgnl_url}") + print() + print("Waiting for confirmation (Ctrl+C to cancel)...") + + start = time.time() + while time.time() - start < 120: + time.sleep(3) + result = signal_rest_get("/v1/accounts") + if result and (isinstance(result, list) and len(result) > 0): + print(f"✅ Device linked! Account: {result}") + return + + print("⚠️ Linking timed out. Please try again.", file=sys.stderr) + + +# --------------------------------------------------------------------------- +# Main / argument parsing +# --------------------------------------------------------------------------- + +def main(): + parser = argparse.ArgumentParser( + prog="sendmsg", + description="Universal message sender for Signal (REST API) and SMS/iMessage", + epilog=( + "Examples:\n" + " %(prog)s --signal --to +18005551212 --text 'Hello!'\n" + " %(prog)s --signal --recipients GROUP_ID --text 'Hello group!'\n" + " %(prog)s --sms --to +18005551212 --text 'Text via SMS'\n" + " %(prog)s --sms --to +18005551212 --text 'Hey' --file ~/pic.jpg\n" + " %(prog)s --csv messages.csv\n" + " %(prog)s --csv messages.csv --delay 2\n" + " %(prog)s --list-signal\n" + " %(prog)s --link-signal\n" + " %(prog)s --link-signal --name 'my-automation'\n" + "\n" + "CSV columns: method, recipient, name, message, account, service, file, delay\n" + "The 'name' field is printed in the status output during CSV sends.\n" + f"Signal REST API: {SIGNAL_REST_URL}\n" + "Config file: ~/.sendmsg.conf\n" + " [settings]\n" + " signal_rest_url = http://localhost:8080\n" + " signal_default_account = +1888222333\n" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + + parser.add_argument("-V", "--version", action="version", version=f"%(prog)s {VERSION}") + + # Method (mutually exclusive) + method = parser.add_argument_group("Messaging method (choose one)") + method.add_argument("--signal", action="store_true", help="Send via Signal") + method.add_argument("--sms", action="store_true", help="Send via SMS/iMessage (macOS Messages)") + method.add_argument("--csv", metavar="FILE", help="Bulk send from a CSV file") + + # Common options + parser.add_argument("--to", nargs="+", metavar="PHONE", help="Recipient phone number(s)") + parser.add_argument("--text", nargs="+", metavar="MSG", help="Message text (repeat for multiple)") + parser.add_argument("--file", "--attach", nargs="+", metavar="PATH", help="Attachment file path(s)") + parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output") + + # Signal-specific + signal_grp = parser.add_argument_group("Signal options") + signal_grp.add_argument("--account", metavar="PHONE", help="Signal account phone number to use") + signal_grp.add_argument("--recipients", metavar="RECIPIENTS", help="Send to Signal group ID (no --to needed)") + signal_grp.add_argument("--voice", action="store_true", help="Send as voice note (Signal only)") + + # Management commands + mgmt = parser.add_argument_group("Management commands") + mgmt.add_argument("--list-signal", action="store_true", help="List linked Signal accounts") + mgmt.add_argument("--link-signal", action="store_true", help="Link a new device to your Signal account") + signal_grp.add_argument("--name", metavar="NAME", help="Device name for Signal linking (default: 'automation')") + + # SMS-specific + sms_grp = parser.add_argument_group("SMS/iMessage options") + sms_grp.add_argument("--service", choices=["imessage", "sms", "auto"], help="Force service (default: auto-detect)") + + # CSV-specific + csv_grp = parser.add_argument_group("CSV options") + csv_grp.add_argument("--delay", metavar="SECONDS", help="Wait N seconds between CSV row sends (default: 0)") + + args = parser.parse_args() + + # Validate: must have a method or management command + if not any([args.signal, args.sms, args.csv, args.list_signal, args.link_signal]): + parser.print_help() + print("\n❌ Error: Specify --signal, --sms, --csv, --list-signal, or --link-signal", file=sys.stderr) + sys.exit(1) + + if sum([args.signal, args.sms]) > 1: + print("❌ Error: Choose only one method (--signal or --sms).", file=sys.stderr) + sys.exit(1) + + # Set defaults + if args.link_signal and not args.name: + args.name = "automation" + + # Dispatch + if args.signal: + cmd_signal(args) + elif args.sms: + cmd_sms(args) + elif args.csv: + cmd_csv(args) + elif args.list_signal: + cmd_list_signal(args) + elif args.link_signal: + cmd_link_signal(args) + + +if __name__ == "__main__": + main() \ No newline at end of file