Merge pull request #1 from docwho76/first-release

commit first release files and docs
This commit is contained in:
Daniel Spisak 2026-06-08 03:43:13 -07:00 committed by GitHub
commit a19513b635
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 958 additions and 1 deletions

25
.gitignore vendored
View File

@ -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 # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/
*.py[codz] *.py[codz]

318
README.md
View File

@ -1,2 +1,318 @@
# sendmsg # 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/*

616
sendmsg Executable file
View File

@ -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 <phone> 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 <message> or --attach <file> 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 <phone> is required for SMS.", file=sys.stderr)
sys.exit(1)
if not args.text:
print("❌ Error: --text <message> 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()