Merge pull request #6 from docwho76/v5.1.0-turd-polishing

more bug fixes and updates
This commit is contained in:
Daniel Spisak 2026-06-13 00:25:14 -07:00 committed by GitHub
commit 83d70b6b23
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 186 additions and 79 deletions

View File

@ -2,6 +2,23 @@
All notable changes to `sendmsg` are documented here. All notable changes to `sendmsg` are documented here.
## 5.1.0
### Added
- **Multiple recipients per Signal send:** `--recipients` now accepts more than one value, and `--to` accepts multiple numbers, so a single invocation can fan out to several groups and/or direct recipients. Each target is sent independently and reported individually; the command exits non-zero if any target fails.
- **`--json` CSV summary:** Emits a machine-readable JSON summary (totals, per-bucket counts, and notes) for unattended runs, as an alternative to the formatted text summary.
- **SMS timeout and retry:** `imsg` invocations now run with a 60-second timeout and are retried up to 3 times with backoff on timeout or transient failure, matching the resilience of the Signal REST path. Previously a hung Messages app could stall an entire batch and SMS sends had no retry.
- **Startup configuration warnings:** A `signal_rest_url` that is not a well-formed `http(s)://` URL now warns at startup, and attempting a Signal send from the built-in placeholder account (`+1234567890`) warns that no real account is configured.
### Changed
- **Skip vs. fail accounting corrected.** Intentional skips — unknown `method`, missing/invalid SMS `service`, `voice` on an SMS row, and empty messages — are now counted under **Skipped** rather than **Failed**. The process exit code is `1` only when a genuine send fails, so deliberate skips no longer cause unattended runs to report failure.
- **Dry runs no longer report sends as successful.** A `--dry-run` now reports a separate "would send" count instead of incrementing the success total for rows that were never sent.
- Empty-message skip notes now distinguish SMS rows (which always require text) from Signal rows (which may be attachment- or voice-only).
### Fixed
- **`--link-signal` false success:** The account snapshot used for new-link detection now tolerates all response shapes returned by the REST API (bare list, `{"accounts": [...]}`, and `{"number": ...}`). Previously a dict-shaped response produced an empty baseline, so any pre-existing account could be misreported as a newly linked device. Account listing and link detection now share one shape-tolerant helper.
- The `--help` epilog and module docstring CSV column lists now include the `voice` column, matching the documented CSV format and the code.
## 5.0.0 ## 5.0.0
### Added ### Added

View File

@ -329,7 +329,7 @@ $ sendmsg --show-config
================================================== ==================================================
⚙️ sendmsg configuration ⚙️ sendmsg configuration
================================================== ==================================================
Version: 5.0.0 Version: 5.1.0
Config file: /Users/you/.sendmsg.conf (found) Config file: /Users/you/.sendmsg.conf (found)
@ -442,6 +442,12 @@ After processing all rows, a summary is printed:
================================================== ==================================================
``` ```
> Intentional skips — unknown method, missing/invalid SMS service, voice on
> an SMS row, and empty messages — are counted under **Skipped**, not
> **Failed**. Only genuine send failures count as **Failed**, and the exit
> code is `1` only when something actually failed to send. Add `--json` for a
> machine-readable summary in unattended runs.
--- ---
## Changelog ## Changelog

176
sendmsg
View File

@ -17,11 +17,11 @@ Usage:
sendmsg --link-signal # Link a new device to your existing Signal account sendmsg --link-signal # Link a new device to your existing Signal account
CSV format (messages.csv): CSV format (messages.csv):
method,recipient,name,message,account,service,file,delay method,recipient,name,message,account,service,file,voice,delay
signal,+1888222333,Alice,Hello via Signal,+1888222333,,, signal,+1888222333,Alice,Hello via Signal,+1888222333,,,,
signal,group.ZzBHd3NZO...,Group alert,Hi team,,,, signal,group.ZzBHd3NZO...,Group alert,Hi team,,,,,
sms,+1234567890,Bob,SMS test,,imessage,~/pic.jpg, sms,+1234567890,Bob,SMS test,,imessage,~/pic.jpg,,
signal,+1888222333,Carol,With delay,,,,3 signal,+1888222333,Carol,With delay,,,,,3
""" """
import argparse import argparse
@ -39,7 +39,7 @@ import urllib.error
import urllib.parse import urllib.parse
import urllib.request import urllib.request
VERSION = "5.0.0" VERSION = "5.1.0"
CONFIG_PATHS = [ CONFIG_PATHS = [
os.path.expanduser("~/.sendmsg.conf"), os.path.expanduser("~/.sendmsg.conf"),
@ -108,6 +108,18 @@ CFG = load_config()
SIGNAL_REST_URL = CFG["signal_rest_url"] SIGNAL_REST_URL = CFG["signal_rest_url"]
SIGNAL_DEFAULT_ACCOUNT = CFG["signal_default_account"] 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) # Helpers — reusable without argparse objects (needed for CSV dispatch)
@ -237,6 +249,14 @@ def send_one_signal(account, recipient, message, group_id=None,
""" """
account = normalize_account(account) 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) target = group_id if group_id else normalize_recipient(recipient)
payload = {"number": account, "recipients": [target]} payload = {"number": account, "recipients": [target]}
@ -314,8 +334,25 @@ def send_one_sms(to, message, service, file=None):
print("❌ SMS send failed: 'imsg' CLI not found on PATH.", file=sys.stderr) print("❌ SMS send failed: 'imsg' CLI not found on PATH.", file=sys.stderr)
return False 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: try:
result = subprocess.run(cmd, capture_output=True, text=True) 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: except OSError as e:
print(f"❌ SMS send failed: could not run 'imsg': {e}", file=sys.stderr) print(f"❌ SMS send failed: could not run 'imsg': {e}", file=sys.stderr)
return False return False
@ -323,10 +360,21 @@ def send_one_sms(to, message, service, file=None):
if result.returncode == 0: if result.returncode == 0:
print(f"✅ SMS/iMessage sent to {to} (service: {service.upper()})") print(f"✅ SMS/iMessage sent to {to} (service: {service.upper()})")
return True return True
else:
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) print(f"❌ SMS send failed (exit {result.returncode}): {result.stderr.strip()}", file=sys.stderr)
return False return False
return False
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# REST API # REST API
@ -456,16 +504,24 @@ def cmd_signal(args):
sys.exit(1) sys.exit(1)
account = normalize_account(args.account or SIGNAL_DEFAULT_ACCOUNT) account = normalize_account(args.account or SIGNAL_DEFAULT_ACCOUNT)
if args.recipients:
recipient = None
group_id = args.recipients
else:
recipient = args.to[0]
group_id = None
message = "\n".join(args.text) if args.text else None 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( ok = send_one_signal(
account=account, account=account,
recipient=recipient, recipient=recipient,
@ -474,16 +530,13 @@ def cmd_signal(args):
attach=attach_files, attach=attach_files,
voice=voice_file, voice=voice_file,
) )
if ok: if ok:
if args.recipients: print(f"✅ Signal message sent to {label}")
print(f"✅ Signal message sent to group {args.recipients}")
elif args.to:
print(f"✅ Signal message sent to {normalize_recipient(args.to[0])}")
else: else:
print("✅ Signal message sent") print(f"❌ Signal message send failed: {label}", file=sys.stderr)
else: all_ok = False
print("❌ Signal message send failed.", file=sys.stderr)
if not all_ok:
sys.exit(1) sys.exit(1)
@ -575,6 +628,7 @@ def cmd_csv(args):
success = 0 success = 0
failed = 0 failed = 0
skipped = 0 skipped = 0
previewed = 0
errors = [] errors = []
for i, row in enumerate(rows, 1): for i, row in enumerate(rows, 1):
@ -593,7 +647,7 @@ def cmd_csv(args):
if method not in ("signal", "sms"): if method not in ("signal", "sms"):
print(f"⚠️ Row {i}: unknown method '{method}' — skipping", file=sys.stderr) print(f"⚠️ Row {i}: unknown method '{method}' — skipping", file=sys.stderr)
failed += 1 skipped += 1
errors.append(f"Row {i}: unknown method '{method}'") errors.append(f"Row {i}: unknown method '{method}'")
continue continue
@ -613,9 +667,13 @@ def cmd_csv(args):
# requires text. # requires text.
has_signal_media = method == "signal" and (file_path or voice_path) has_signal_media = method == "signal" and (file_path or voice_path)
if not message and not has_signal_media: if not message and not has_signal_media:
print(f"⚠️ Row {i}: empty message — skipping", file=sys.stderr) 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 skipped += 1
errors.append(f"Row {i}: empty message") errors.append(f"Row {i}: {reason}")
continue continue
if method == "sms": if method == "sms":
@ -625,7 +683,7 @@ def cmd_csv(args):
f"(method 'signal') — skipping", f"(method 'signal') — skipping",
file=sys.stderr, file=sys.stderr,
) )
failed += 1 skipped += 1
errors.append(f"Row {i}: voice not supported for SMS") errors.append(f"Row {i}: voice not supported for SMS")
continue continue
if service not in ("sms", "imessage"): if service not in ("sms", "imessage"):
@ -634,7 +692,7 @@ def cmd_csv(args):
f"(got '{service or ''}') — skipping", f"(got '{service or ''}') — skipping",
file=sys.stderr, file=sys.stderr,
) )
failed += 1 skipped += 1
errors.append(f"Row {i}: invalid/missing SMS service") errors.append(f"Row {i}: invalid/missing SMS service")
continue continue
@ -655,7 +713,7 @@ def cmd_csv(args):
extras.append(f"service={service}") extras.append(f"service={service}")
if extras: if extras:
print(f" ({', '.join(extras)})") print(f" ({', '.join(extras)})")
success += 1 previewed += 1
continue continue
if method == "signal": if method == "signal":
@ -691,11 +749,27 @@ def cmd_csv(args):
time.sleep(effective_delay) time.sleep(effective_delay)
# Summary # 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()
print("=" * 50) print("=" * 50)
print("📊 CSV Send Summary" + (" (DRY RUN)" if args.dry_run else "")) print("📊 CSV Send Summary" + (" (DRY RUN)" if args.dry_run else ""))
print("=" * 50) print("=" * 50)
print(f" Total rows: {total}") print(f" Total rows: {total}")
if args.dry_run:
print(f" 🔎 Would send: {previewed}")
else:
print(f" ✅ Success: {success}") print(f" ✅ Success: {success}")
print(f" ⏭️ Skipped: {skipped}") print(f" ⏭️ Skipped: {skipped}")
print(f" ❌ Failed: {failed}") print(f" ❌ Failed: {failed}")
@ -750,6 +824,24 @@ def cmd_show_config(args):
print("=" * 50) 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): def cmd_list_signal(args):
"""List linked Signal accounts via the REST API.""" """List linked Signal accounts via the REST API."""
result = signal_rest_get("/v1/accounts") result = signal_rest_get("/v1/accounts")
@ -757,14 +849,7 @@ def cmd_list_signal(args):
print(f"❌ Failed to list accounts (cannot reach {SIGNAL_REST_URL})", file=sys.stderr) print(f"❌ Failed to list accounts (cannot reach {SIGNAL_REST_URL})", file=sys.stderr)
sys.exit(1) sys.exit(1)
if isinstance(result, list): accounts = _extract_accounts(result)
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: if not accounts:
print("No Signal accounts linked. Use --link-signal to set one up.") print("No Signal accounts linked. Use --link-signal to set one up.")
@ -785,9 +870,7 @@ def cmd_link_signal(args):
# Snapshot existing accounts so we can detect a genuinely NEW link rather # Snapshot existing accounts so we can detect a genuinely NEW link rather
# than reporting success just because a primary account already exists. # than reporting success just because a primary account already exists.
before = signal_rest_get("/v1/accounts") before = signal_rest_get("/v1/accounts")
before_set = set() before_set = set(_extract_accounts(before))
if isinstance(before, list):
before_set = {str(a) for a in before}
payload = {"name": name} payload = {"name": name}
result = signal_rest_post("/v1/link", payload=payload) result = signal_rest_post("/v1/link", payload=payload)
@ -811,9 +894,7 @@ def cmd_link_signal(args):
start = time.time() start = time.time()
while time.time() - start < 120: while time.time() - start < 120:
time.sleep(3) time.sleep(3)
current = signal_rest_get("/v1/accounts") current_set = set(_extract_accounts(signal_rest_get("/v1/accounts")))
if isinstance(current, list):
current_set = {str(a) for a in current}
new_accounts = current_set - before_set new_accounts = current_set - before_set
if new_accounts: if new_accounts:
print(f"✅ Device linked! New account(s): {', '.join(sorted(new_accounts))}") print(f"✅ Device linked! New account(s): {', '.join(sorted(new_accounts))}")
@ -843,7 +924,7 @@ def main():
" %(prog)s --list-signal\n" " %(prog)s --list-signal\n"
" %(prog)s --link-signal --name 'my-automation'\n" " %(prog)s --link-signal --name 'my-automation'\n"
"\n" "\n"
"CSV columns: method, recipient, name, message, account, service, file, delay\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" "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" "For SMS rows the 'service' column must be 'sms' or 'imessage'.\n"
f"Signal REST API: {SIGNAL_REST_URL}\n" f"Signal REST API: {SIGNAL_REST_URL}\n"
@ -873,7 +954,8 @@ def main():
# Signal-specific # Signal-specific
signal_grp = parser.add_argument_group("Signal options") 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("--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("--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("--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')") signal_grp.add_argument("--name", metavar="NAME", help="Device name for Signal linking (default: 'automation')")
@ -895,6 +977,8 @@ def main():
csv_grp.add_argument("--dry-run", action="store_true", help="Preview a CSV batch without sending") 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", csv_grp.add_argument("--yes", "-y", action="store_true",
help="Skip the large-batch confirmation prompt") 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() args = parser.parse_args()