mirror of
https://github.com/docwho76/sendmsg.git
synced 2026-08-31 01:41:10 -07:00
1034 lines
38 KiB
Python
Executable File
1034 lines
38 KiB
Python
Executable File
#!/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.0"
|
||
|
||
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()
|