sendmsg/sendmsg
2026-06-13 01:37:46 -07:00

1034 lines
38 KiB
Python
Executable File
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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.<base64>' 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
<uuid>' 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:<mime>;filename=<name>;base64,<data>'. 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 <phone> 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 <message>, --attach <file>, or --voice <audio> is required.", file=sys.stderr)
sys.exit(1)
account = normalize_account(args.account or SIGNAL_DEFAULT_ACCOUNT)
message = "\n".join(args.text) if args.text else None
# Build the list of targets. --recipients values are treated as group IDs
# or raw recipients; --to values are direct phone numbers. Either may
# contain more than one entry, so a single invocation can fan out.
targets = [] # list of (recipient, group_id, label)
if args.recipients:
for r in args.recipients:
if is_group_id(r):
targets.append((None, r, f"group {r}"))
else:
targets.append((r, None, normalize_recipient(r)))
if args.to:
for t in args.to:
targets.append((t, None, normalize_recipient(t)))
all_ok = True
for recipient, group_id, label in targets:
ok = send_one_signal(
account=account,
recipient=recipient,
message=message,
group_id=group_id,
attach=attach_files,
voice=voice_file,
)
if ok:
print(f"✅ Signal message sent to {label}")
else:
print(f"❌ Signal message send failed: {label}", file=sys.stderr)
all_ok = False
if not all_ok:
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)
if not args.service:
print("❌ Error: --service <sms|imessage> is required for SMS.", file=sys.stderr)
sys.exit(1)
msg_text = "\n".join(args.text)
attach_files = getattr(args, "file", None) or getattr(args, "attach", None)
# imsg accepts a single --file; send the first valid one and warn on extras.
chosen_file = None
if attach_files:
for f in attach_files:
ef = expand_path(f)
if not os.path.isfile(ef):
print(f"⚠️ Warning: file not found, skipping: {ef}", file=sys.stderr)
continue
if chosen_file is None:
chosen_file = ef
else:
print(f"⚠️ Warning: SMS supports one attachment; ignoring extra file: {ef}", file=sys.stderr)
ok = send_one_sms(
to=args.to[0],
message=msg_text,
service=args.service,
file=chosen_file,
)
if not ok:
sys.exit(1)
def cmd_csv(args):
"""Bulk send messages from a CSV file.
Expected CSV columns:
method, recipient, name, message, account, service, file, voice, delay
"""
filepath = args.csv
if not os.path.isfile(filepath):
print(f"❌ Error: file not found: {filepath}", file=sys.stderr)
sys.exit(1)
# Global delay from --delay applies between every send unless a row
# specifies its own delay (which takes precedence).
global_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)
# Count the rows that will actually be attempted (non-blank).
actionable = [
r for r in rows
if (r.get("method", "").strip()
or r.get("message", "").strip()
or r.get("recipient", "").strip()
or r.get("file", "").strip()
or r.get("voice", "").strip())
]
if args.dry_run:
print(f"🔎 Dry run: {len(actionable)} row(s) would be processed from {filepath}\n")
elif len(actionable) > CSV_CONFIRM_THRESHOLD and not args.yes:
try:
resp = input(
f"⚠️ About to send {len(actionable)} messages. Continue? [y/N] "
).strip().lower()
except EOFError:
resp = "n"
if resp not in ("y", "yes"):
print("Aborted.")
sys.exit(1)
total = len(rows)
success = 0
failed = 0
skipped = 0
previewed = 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()
voice_path = row.get("voice", "").strip() or None
# Skip blank rows
if not method and not message and not to and not voice_path:
continue
if not method:
method = "signal" # default
if method not in ("signal", "sms"):
print(f"⚠️ Row {i}: unknown method '{method}' — skipping", file=sys.stderr)
skipped += 1
errors.append(f"Row {i}: unknown method '{method}'")
continue
# Common fields
account = row.get("account", "").strip() or SIGNAL_DEFAULT_ACCOUNT
service = row.get("service", "").strip().lower() or None
file_path = row.get("file", "").strip() or None
delay_str = row.get("delay", "").strip()
try:
row_delay = float(delay_str) if delay_str else None
except ValueError:
print(f"⚠️ Row {i}: invalid delay '{delay_str}' — ignoring", file=sys.stderr)
row_delay = None
# A message is required UNLESS there is an attachment or a voice note
# (Signal can send attachment-only / voice-only messages). SMS still
# requires text.
has_signal_media = method == "signal" and (file_path or voice_path)
if not message and not has_signal_media:
if method == "sms":
reason = "empty message (SMS requires text)"
else:
reason = "empty message (no text, attachment, or voice)"
print(f"⚠️ Row {i}: {reason} — skipping", file=sys.stderr)
skipped += 1
errors.append(f"Row {i}: {reason}")
continue
if method == "sms":
if voice_path:
print(
f"⚠️ Row {i}: voice notes are only supported for Signal "
f"(method 'signal') — skipping",
file=sys.stderr,
)
skipped += 1
errors.append(f"Row {i}: voice not supported for SMS")
continue
if service not in ("sms", "imessage"):
print(
f"⚠️ Row {i}: SMS requires service 'sms' or 'imessage' "
f"(got '{service or ''}') — skipping",
file=sys.stderr,
)
skipped += 1
errors.append(f"Row {i}: invalid/missing SMS service")
continue
label = f"{name} ({to})" if name else to or "(group)"
prefix = "[DRY] " if args.dry_run else ""
print(f"{prefix}[{i}/{total}] {method.upper()}{label}")
if args.dry_run:
# Report what would happen without sending.
extras = []
if file_path:
extras.append(f"file={file_path}")
if voice_path:
extras.append(f"voice={voice_path}")
if method == "signal" and is_group_id(to):
extras.append("group")
if method == "sms":
extras.append(f"service={service}")
if extras:
print(f" ({', '.join(extras)})")
previewed += 1
continue
if method == "signal":
# Detect groups robustly (issue #3): raw base64 group IDs do NOT
# start with 'group.' and were previously sent down the 1:1
# recipient path, producing 'Unregistered user <uuid>' errors.
group_id = to if is_group_id(to) else None
result = send_one_signal(
account=account,
recipient=to,
message=message,
group_id=group_id,
attach=file_path,
voice=voice_path,
)
else: # sms
result = send_one_sms(
to=normalize_account(to),
message=message,
service=service,
file=file_path,
)
if result:
success += 1
else:
failed += 1
errors.append(f"Row {i}: failed to send")
# Per-row delay overrides global; otherwise use the global --delay.
effective_delay = row_delay if row_delay is not None else global_delay
if effective_delay > 0 and not args.dry_run:
time.sleep(effective_delay)
# Summary
if getattr(args, "json_output", False):
summary = {
"total": total,
"success": success,
"skipped": skipped,
"failed": failed,
"dry_run": bool(args.dry_run),
"notes": errors,
}
if args.dry_run:
summary["would_send"] = previewed
print(json.dumps(summary, indent=2))
else:
print()
print("=" * 50)
print("📊 CSV Send Summary" + (" (DRY RUN)" if args.dry_run else ""))
print("=" * 50)
print(f" Total rows: {total}")
if args.dry_run:
print(f" 🔎 Would send: {previewed}")
else:
print(f" ✅ Success: {success}")
print(f" ⏭️ Skipped: {skipped}")
print(f" ❌ Failed: {failed}")
if errors:
print()
print(" Notes:")
for e in errors:
print(f"{e}")
print("=" * 50)
if failed > 0:
sys.exit(1)
def cmd_show_config(args):
"""Report the resolved configuration and where each value came from."""
sources = CFG.get("_sources", {})
config_path = CFG.get("_config_path")
print("=" * 50)
print("⚙️ sendmsg configuration")
print("=" * 50)
print(f" Version: {VERSION}")
print()
if config_path:
print(f" Config file: {config_path} (found)")
else:
searched = ", ".join(CONFIG_PATHS)
print(f" Config file: none found (searched: {searched})")
print()
print(f" signal_rest_url: {SIGNAL_REST_URL}")
print(f" └─ source: {sources.get('signal_rest_url', 'unknown')}")
print()
print(f" signal_default_account: {SIGNAL_DEFAULT_ACCOUNT}")
print(f" └─ source: {sources.get('signal_default_account', 'unknown')}")
print()
print(" Resolution order: env var > config file > built-in default")
print("=" * 50)
# Optionally probe whether the Signal REST API is reachable.
if getattr(args, "verbose", False):
print()
print(" Checking Signal REST API reachability...")
result = signal_rest_get("/v1/accounts")
if result is None:
print(f" ❌ Not reachable at {SIGNAL_REST_URL}")
else:
print(f" ✅ Reachable at {SIGNAL_REST_URL}")
print("=" * 50)
def _extract_accounts(result):
"""Normalize the various shapes /v1/accounts may return into a list of str.
The REST API has returned a bare list, a {'accounts': [...]} dict, and a
single {'number': ...} dict across versions. Returns [] for None/unknown.
"""
if result is None:
return []
if isinstance(result, list):
return [str(a) for a in result]
if isinstance(result, dict):
if "accounts" in result and isinstance(result["accounts"], list):
return [str(a) for a in result["accounts"]]
if "number" in result:
return [str(result["number"])]
return []
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)
accounts = _extract_accounts(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"
# Snapshot existing accounts so we can detect a genuinely NEW link rather
# than reporting success just because a primary account already exists.
before = signal_rest_get("/v1/accounts")
before_set = set(_extract_accounts(before))
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 = ""
if isinstance(result, dict):
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("🔗 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)
current_set = set(_extract_accounts(signal_rest_get("/v1/accounts")))
new_accounts = current_set - before_set
if new_accounts:
print(f"✅ Device linked! New account(s): {', '.join(sorted(new_accounts))}")
return
print("⚠️ Linking timed out (no new account detected). 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 --signal --to +18005551212 --voice ~/note.m4a\n"
" %(prog)s --sms --to +18005551212 --text 'Text' --service sms\n"
" %(prog)s --sms --to +18005551212 --text 'Hey' --service imessage --file ~/pic.jpg\n"
" %(prog)s --csv messages.csv\n"
" %(prog)s --csv messages.csv --delay 2\n"
" %(prog)s --csv messages.csv --dry-run\n"
" %(prog)s --list-signal\n"
" %(prog)s --link-signal --name 'my-automation'\n"
"\n"
"CSV columns: method, recipient, name, message, account, service, file, voice, delay\n"
"The 'name' field is printed in the status output during CSV sends.\n"
"For SMS rows the 'service' column must be 'sms' or 'imessage'.\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
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", dest="file",
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", nargs="+", metavar="RECIPIENT",
help="Send to one or more Signal group IDs / recipients (no --to needed)")
signal_grp.add_argument("--voice", metavar="AUDIO", help="Send an audio file as a Signal voice message")
signal_grp.add_argument("--name", metavar="NAME", help="Device name for Signal linking (default: 'automation')")
# 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")
mgmt.add_argument("--show-config", action="store_true",
help="Print resolved configuration and where each value came from")
# SMS-specific
sms_grp = parser.add_argument_group("SMS/iMessage options")
sms_grp.add_argument("--service", choices=["imessage", "sms"],
help="Service to use: 'imessage' or 'sms' (required for --sms)")
# 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)")
csv_grp.add_argument("--dry-run", action="store_true", help="Preview a CSV batch without sending")
csv_grp.add_argument("--yes", "-y", action="store_true",
help="Skip the large-batch confirmation prompt")
csv_grp.add_argument("--json", action="store_true", dest="json_output",
help="Emit a machine-readable JSON summary (for unattended runs)")
args = parser.parse_args()
# cmd_signal still reads args.attach in places; keep an alias.
args.attach = args.file
# Validate: must have exactly one method or management command.
methods = {
"--signal": args.signal,
"--sms": args.sms,
"--csv": bool(args.csv),
"--list-signal": args.list_signal,
"--link-signal": args.link_signal,
"--show-config": args.show_config,
}
chosen = [name for name, on in methods.items() if on]
if not chosen:
parser.print_help()
print("\n❌ Error: Specify --signal, --sms, --csv, --list-signal, --link-signal, or --show-config", file=sys.stderr)
sys.exit(1)
if len(chosen) > 1:
print(f"❌ Error: Choose only one action (got: {', '.join(chosen)}).", file=sys.stderr)
sys.exit(1)
# --voice is single-file only.
if args.voice and args.file and len(args.file) > 0:
# Allow voice + attachments, but warn it's two separate sends.
print(" Note: --voice and --attach will be sent as separate messages.", file=sys.stderr)
# 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)
elif args.show_config:
cmd_show_config(args)
if __name__ == "__main__":
main()