v5.0.0 polish and fixes, add voice msgs for signal

This commit is contained in:
Daniel Spisak 2026-06-12 23:37:12 -07:00
parent 0c10c9cbc8
commit 73c143d38f
3 changed files with 545 additions and 229 deletions

43
CHANGELOG.md Normal file
View File

@ -0,0 +1,43 @@
# Changelog
All notable changes to `sendmsg` are documented here.
## 5.0.0
### Added
- **Voice messages:** `--voice <audio>` sends an audio file (`.m4a`, `.aac`, `.ogg`, `.opus`, `.mp3`, `.wav`) as a Signal voice note. Delivered with the REST API's `voice` flag so it renders as a playable voice note rather than a file attachment. Also supported in CSV bulk sends via a new `voice` column (Signal rows only; voice-only rows with no message text are allowed).
- **`--dry-run`:** Preview a CSV batch — every row is reported (group/file/service noted) without anything being sent.
- **Large-batch confirmation:** CSV sends of more than 50 rows now prompt for confirmation. `--yes` / `-y` skips the prompt for unattended runs.
- **Retry with backoff:** Transient Signal REST failures (HTTP 429/500/502/503/504 and network errors) are retried up to 3 times with increasing delay.
- **Attachment size guard:** Attachments larger than 100 MB are rejected before encoding, with a warning.
### Changed
- **SMS service is now explicit.** The `auto` service option has been removed. `--service` accepts only `imessage` or `sms`, and is **required** for `--sms`. CSV `sms` rows must specify a valid `service` or they are skipped and reported.
- CSV `signal` rows may now be **attachment-only** (no message text). Previously any row without a `message` was skipped, which silently dropped valid attachment-only Signal sends. SMS rows still require text.
- Signal attachments can now be sent **multiple at a time** from any path, including CSV.
- The CSV summary now distinguishes **skipped** rows from **failed** rows.
### Fixed
- Bare (no `+`) phone numbers are no longer mis-detected as Signal group IDs; group detection and phone-number detection now agree.
- Recipients are consistently normalized with a leading `+` on both the direct `--to` path and CSV rows.
- Multi-attachment Signal sends now report overall success/failure correctly instead of reflecting only the last file's result.
- Choosing more than one action (e.g. `--list-signal --link-signal`) is now rejected instead of silently running the first.
- `--link-signal` snapshots existing accounts and only reports success when a genuinely new account appears, fixing the false "Device linked!" when a primary account was already present.
- `imsg` presence is checked before invocation, producing a clean error instead of an uncaught `FileNotFoundError`.
- Removed a redundant internal assignment in the REST POST helper.
## 4.3.0
- **Fixed:** Signal attachments now send correctly. Files are base64-encoded and delivered in the JSON request body via `base64_attachments`; the previous multipart upload was rejected by the Signal REST API with HTTP 400.
- Attachment filename and MIME type are now preserved using a data-URI form.
## 4.2.0
- **Added:** `--show-config` to report resolved settings and their sources (`-v` also checks REST API reachability).
- Config resolution rewritten to correctly source-track values and avoid empty values silently falling through to defaults.
## 4.1.0
- **Fixed:** CSV attachment paths using `~` are now expanded, so Signal/SMS attachments from CSV rows are no longer silently dropped.
- **Fixed (issue #3):** Group messages are detected robustly; raw (non-`group.`) group IDs are no longer mis-sent down the direct-message path.
- **Fixed:** Multipart text/bytes join crash; consistent `+` normalization of account numbers; global `--delay` now applies between rows; missing files are skipped with a warning instead of being passed to the sender; duplicate/dead Signal send branches collapsed.
### 4.0.0
- First public release, MVP status. Earlier versions were internal only builds.

208
README.md
View File

@ -4,7 +4,7 @@
`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`] CLI. `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`] CLI.
It supports individual direct messages, Signal group broadcasts, file attachments, and bulk sending from CSV files — all with a single, consistent interface. It supports individual direct messages, Signal group broadcasts, file attachments, Signal voice messages, and bulk sending from CSV files — all with a single, consistent interface.
--- ---
@ -15,6 +15,7 @@ It supports individual direct messages, Signal group broadcasts, file attachment
- [Installation](#installation) - [Installation](#installation)
- [Usage](#usage) - [Usage](#usage)
* [Signal Messages](#signal-messages) * [Signal Messages](#signal-messages)
* [Voice Messages](#voice-messages)
* [SMS / iMessage](#sms--imessage) * [SMS / iMessage](#sms--imessage)
* [Bulk Send from CSV](#bulk-send-from-csv) * [Bulk Send from CSV](#bulk-send-from-csv)
* [Management Commands](#management-commands) * [Management Commands](#management-commands)
@ -23,6 +24,7 @@ It supports individual direct messages, Signal group broadcasts, file attachment
- [Examples](#examples) - [Examples](#examples)
- [Error Handling](#error-handling) - [Error Handling](#error-handling)
- [Summary Output](#summary-output) - [Summary Output](#summary-output)
- [Changelog](#changelog)
--- ---
@ -53,7 +55,7 @@ It supports individual direct messages, Signal group broadcasts, file attachment
└─────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────┘
``` ```
**Signal path:** `sendmsg` → JSON HTTP POST → `signal-cli-rest-api` → Docker container → Signal network. Attachments are base64-encoded inline in the JSON request body (the API does not accept multipart uploads). **Signal path:** `sendmsg` → JSON HTTP POST → `signal-cli-rest-api` → Docker container → Signal network. Attachments are base64-encoded inline in the JSON request body (the API does not accept multipart uploads). Voice messages are sent the same way with the API's `voice` flag set so they render as playable voice notes.
**SMS/iMessage path:** `sendmsg` → subprocess call → `imsg` CLI → macOS Messages framework → carrier/Apple. **SMS/iMessage path:** `sendmsg` → subprocess call → `imsg` CLI → macOS Messages framework → carrier/Apple.
@ -63,31 +65,32 @@ It supports individual direct messages, Signal group broadcasts, file attachment
### Required ### Required
| Dependency | Version | Purpose | | Dependency | Version | Purpose |
| ---------- | ----------------- | --------------------------------- | | ---------- | ------- | -------------- |
| Python 3 | 3.9+ | Script runtime | | Python 3 | 3.9+ | Script runtime |
| macOS 12+ | Monterey or later | Required for iMessage/SMS support |
> `sendmsg` uses only the Python standard library — no third-party packages to install. > `sendmsg` uses only the Python standard library — no third-party packages to install.
### Signal (optional — only needed for `--signal`) ### Signal (optional — only needed for `--signal`)
| Dependency | Version | Purpose | | Dependency | Version | Purpose |
| ---------------------------- | ----------------------------- | ------------------------------ | | ---------------------------- | ------- | ------------------------------ |
| Docker | 24+ | Container runtime | | Docker | 24+ | Container runtime |
| `signal-cli-rest-api` | latest | Signal REST API server | | `signal-cli-rest-api` | latest | Signal REST API server |
| `signal-cli` (via container) | latest | Signal protocol implementation | | `signal-cli` (via container) | latest | Signal protocol implementation |
> Signal sends only need network access to the REST API endpoint — they do
> not require macOS.
### SMS/iMessage (optional — only needed for `--sms`) ### SMS/iMessage (optional — only needed for `--sms`)
| Dependency | Version | Purpose | | Dependency | Version | Purpose |
| ---------- | ------- | --------------------------- | | ---------- | ----------------- | --------------------------- |
| `imsg` CLI | latest | macOS Messages / SMS bridge | | macOS 12+ | Monterey or later | Native Messages app |
| `imsg` CLI | latest | macOS Messages / SMS bridge |
### System > SMS/iMessage uses the native macOS Messages app, so macOS 12+ is required
> for the `--sms` path only.
- **macOS** — The script is designed for macOS; SMS/iMessage uses the native Messages app.
- **Network access** — Required to reach the Signal REST API endpoint.
--- ---
@ -158,32 +161,69 @@ sendmsg --signal --to +18885551212 --text "Line 1" --text "Line 2"
# Send multiple attachments # Send multiple attachments
sendmsg --signal --to +18885551212 --text "Files" --attach ~/doc.pdf ~/pic.jpg sendmsg --signal --to +18885551212 --text "Files" --attach ~/doc.pdf ~/pic.jpg
# Send an attachment with no text
sendmsg --signal --to +18885551212 --attach ~/report.pdf
``` ```
> Attachments are read, base64-encoded, and sent inside the JSON request to > Attachments are read, base64-encoded, and sent inside the JSON request to
> the Signal REST API. The original filename and detected MIME type are > the Signal REST API. The original filename and detected MIME type are
> preserved so recipients see the correct file name and type. > preserved so recipients see the correct file name and type. Attachments
> larger than 100 MB are rejected with a warning.
### Voice Messages
Send an audio file as a Signal **voice note** (a playable voice message,
not a file attachment) with `--voice`:
```
# Send a voice message
sendmsg --signal --to +18885551212 --voice ~/note.m4a
# Send a voice message to a group
sendmsg --signal --recipients group.TestGroupHash --voice ~/briefing.ogg
# Voice message with accompanying text
sendmsg --signal --to +18885551212 --voice ~/note.m4a --text "Listen to this"
```
> Recognized audio formats: `.m4a`, `.aac`, `.ogg`, `.opus`, `.mp3`, `.wav`.
> A voice note is sent as its own message; if you combine `--voice` with
> `--attach`, the voice note and the file attachments are delivered as
> separate messages.
### SMS / iMessage ### SMS / iMessage
``` The `--service` flag is **required** and must be either `imessage` or `sms`.
# Send via iMessage (auto-detected)
sendmsg --sms --to +18885551212 --text "Hello via iMessage!"
# Force SMS service ```
# Send via iMessage
sendmsg --sms --to +18885551212 --text "Hello via iMessage!" --service imessage
# Send via SMS
sendmsg --sms --to +18885551212 --text "Hello via SMS!" --service sms sendmsg --sms --to +18885551212 --text "Hello via SMS!" --service sms
# Send with an attachment # Send with an attachment
sendmsg --sms --to +18885551212 --text "Photo attached" --file ~/photo.jpg sendmsg --sms --to +18885551212 --text "Photo attached" --service imessage --file ~/photo.jpg
``` ```
> SMS/iMessage supports a single attachment per message; if multiple files
> are supplied, the first valid one is used and the rest are skipped with a
> warning.
### Bulk Send from CSV ### Bulk Send from CSV
``` ```
sendmsg --csv messages.csv # Send all rows sendmsg --csv messages.csv # Send all rows
sendmsg --csv messages.csv --delay 2 # Wait 2 seconds between every send sendmsg --csv messages.csv --delay 2 # Wait 2 seconds between every send
sendmsg --csv messages.csv --dry-run # Preview every row without sending
sendmsg --csv messages.csv --yes # Skip the large-batch confirmation
``` ```
> Batches of more than 50 rows prompt for confirmation before sending. Use
> `--yes` / `-y` to skip the prompt for unattended runs, or `--dry-run` to
> preview exactly what would be sent first.
### Management Commands ### Management Commands
``` ```
@ -205,31 +245,38 @@ sendmsg --show-config -v # also checks whether the Signal REST API is reachabl
Create a CSV file with the following columns: Create a CSV file with the following columns:
| Column | Required | Description | | Column | Required | Description |
| ----------- | -------- | ----------------------------------------------------------------------------- | | ----------- | -------- | ------------------------------------------------------------------------------------ |
| `method` | Yes | `signal` or `sms` (defaults to `signal` if left blank) | | `method` | Yes | `signal` or `sms` (defaults to `signal` if left blank) |
| `recipient` | Yes | Phone number, or a Signal group ID (`group.XXXX` or a raw group key) | | `recipient` | Yes | Phone number, or a Signal group ID (`group.XXXX` or a raw group key) |
| `name` | No | Display name shown during status output (e.g., "Alice", "Marketing Group") | | `name` | No | Display name shown during status output (e.g., "Alice", "Marketing Group") |
| `message` | Yes | The message text to send | | `message` | Varies | Message text. Required for `sms` rows and for `signal` rows without a `file`/`voice`. |
| `account` | No | Signal account phone number (defaults to `$SIGNAL_ACCOUNT` / config value) | | `account` | No | Signal account phone number (defaults to `$SIGNAL_ACCOUNT` / config value) |
| `service` | No | SMS service: `imessage`, `sms`, or `auto` (SMS only) | | `service` | Varies | SMS service: `imessage` or `sms`. **Required** on `sms` rows; ignored for `signal`. |
| `file` | No | Path to an attachment file. `~` and environment variables are expanded. | | `file` | No | Path to an attachment file. `~` and environment variables are expanded. |
| `delay` | No | Seconds to wait after this row is sent (overrides the global `--delay`). | | `voice` | No | Path to an audio file to send as a Signal **voice note**. Signal only. |
| `delay` | No | Seconds to wait after this row is sent (overrides the global `--delay`). |
**Notes** **Notes**
- A `message` is required on every row; rows with no message are skipped and reported. - `signal` rows may be **attachment-only** or **voice-only**: if a `file` or `voice` is present, the `message` may be left blank. `sms` rows always require a `message`.
- Group recipients are auto-detected: any `recipient` that is not a `+`-prefixed phone number is treated as a Signal group ID. - The `voice` column is **Signal only**. An `sms` row with a `voice` value is skipped and reported, since SMS/iMessage has no voice-note concept.
- A voice note is sent as its own message; if a row has both `voice` and `file`, they are delivered as separate messages.
- Recognized voice formats: `.m4a`, `.aac`, `.ogg`, `.opus`, `.mp3`, `.wav`.
- `sms` rows require a `service` of `imessage` or `sms`; rows with a missing or invalid service are skipped and reported.
- Group recipients are auto-detected: any `recipient` that is not a phone number is treated as a Signal group ID. Bare (no `+`) phone numbers are recognized as numbers, not groups.
- File paths starting with `~` are expanded to the home directory of the user running the script. If a named attachment cannot be found, a warning is printed and the message is still sent without it. - File paths starting with `~` are expanded to the home directory of the user running the script. If a named attachment cannot be found, a warning is printed and the message is still sent without it.
### Example CSV ### Example CSV
``` ```
method,recipient,name,message,account,service,file,delay method,recipient,name,message,account,service,file,voice,delay
signal,+18885551111,Alice,Hello via Signal,+18885551111,,, signal,+18885551111,Alice,Hello via Signal,+18885551111,,,,
signal,group.ZzBHd3NZO...,Team Alert,Morning update for the team,,,, signal,group.ZzBHd3NZO...,Team Alert,Morning update for the team,,,,,
sms,+18885552222,Bob,SMS test,,,~/pic.jpg, signal,+18885552222,Report,,,,~/report.pdf,,
signal,+18885553333,Carol,With a delay after this row,,,,3 signal,+18885556666,Briefing,,,,,~/briefing.m4a,
sms,+18885553333,Bob,SMS test,,imessage,~/pic.jpg,,
signal,+18885554444,Carol,With a delay after this row,,,,,3
``` ```
### Status Output ### Status Output
@ -237,13 +284,16 @@ signal,+18885553333,Carol,With a delay after this row,,,,3
During a bulk send, each row prints its name (if provided): During a bulk send, each row prints its name (if provided):
``` ```
[1/4] SIGNAL → Alice (+18885551111) [1/5] SIGNAL → Alice (+18885551111)
[2/4] SIGNAL → Team Alert (group.ZzBHd3NZO...) [2/5] SIGNAL → Team Alert (group.ZzBHd3NZO...)
[3/4] SMS → Bob (+18885552222) [3/5] SIGNAL → Report (+18885552222)
[4/4] SIGNAL → Carol (+18885553333) [4/5] SMS → Bob (+18885553333)
[5/5] SIGNAL → Carol (+18885554444)
``` ```
If no `name` is provided, the recipient is shown instead. If no `name` is provided, the recipient is shown instead. A `--dry-run`
prefixes each line with `[DRY]` and notes group/file/service details
without sending.
--- ---
@ -279,7 +329,7 @@ $ sendmsg --show-config
================================================== ==================================================
⚙️ sendmsg configuration ⚙️ sendmsg configuration
================================================== ==================================================
Version: 4.3.0 Version: 5.0.0
Config file: /Users/you/.sendmsg.conf (found) Config file: /Users/you/.sendmsg.conf (found)
@ -310,17 +360,17 @@ sendmsg --csv messages.csv
### Daily Broadcast ### Daily Broadcast
``` ```
method,recipient,name,message,account,service,file,delay method,recipient,name,message,account,service,file,voice,delay
signal,group.ZzBHd3NZO...,Daily Update,Good morning team! Here's your daily briefing.,+18002222222,,, signal,group.ZzBHd3NZO...,Daily Update,Good morning team! Here's your daily briefing.,+18002222222,,,,
``` ```
### Personalized Outreach ### Personalized Outreach
``` ```
method,recipient,name,message,account,service,file,delay method,recipient,name,message,account,service,file,voice,delay
signal,+18885551111,John,Hi John hope you're doing well,,,, signal,+18885551111,John,Hi John hope you're doing well,,,,,
sms,+18885552222,Jane,Hey Jane just checking in,,,, sms,+18885552222,Jane,Hey Jane just checking in,,imessage,,,
signal,+18885553333,Alex,Alex don't forget the meeting tomorrow at 3pm,,,, signal,+18885553333,Alex,Alex don't forget the meeting tomorrow at 3pm,,,,,
``` ```
> Avoid commas inside the `message` field unless the field is quoted, since > Avoid commas inside the `message` field unless the field is quoted, since
@ -329,18 +379,29 @@ signal,+18885553333,Alex,Alex don't forget the meeting tomorrow at 3pm,,,,
### With Attachments ### With Attachments
``` ```
method,recipient,name,message,account,service,file,delay method,recipient,name,message,account,service,file,voice,delay
signal,+18885551111,John,Here's the report you asked for,+18002222222,,~/Downloads/report.pdf, signal,+18885551111,John,Here's the report you asked for,+18002222222,,~/Downloads/report.pdf,,
sms,+18885552222,Jane,Photo from the event,,,~/Photos/event.jpg, sms,+18885552222,Jane,Photo from the event,,imessage,~/Photos/event.jpg,,
``` ```
### With Voice Messages
```
method,recipient,name,message,account,service,file,voice,delay
signal,+18885551111,John,Listen to this update,,,,~/recordings/update.m4a,
signal,group.ZzBHd3NZO...,Team,,,,,~/recordings/standup.ogg,
```
> The `voice` column is Signal only and may be used with or without
> `message` text.
### With Delays ### With Delays
``` ```
method,recipient,name,message,account,service,file,delay method,recipient,name,message,account,service,file,voice,delay
signal,+18885551111,Alice,First message,,,, signal,+18885551111,Alice,First message,,,,,
signal,+18885552222,Bob,Second message after a 3s pause,,,,3 signal,+18885552222,Bob,Second message after a 3s pause,,,,,3
signal,+18885553333,Charlie,Third message,,,, signal,+18885553333,Charlie,Third message,,,,,
``` ```
A per-row `delay` value takes precedence over the global `--delay` flag for A per-row `delay` value takes precedence over the global `--delay` flag for
@ -351,8 +412,12 @@ that row.
## Error Handling ## Error Handling
- **Unknown method:** Rows with invalid `method` values are skipped and reported in the summary. - **Unknown method:** Rows with invalid `method` values are skipped and reported in the summary.
- **Empty message:** Rows without a `message` are skipped and reported. - **Missing SMS service:** `sms` rows without a valid `service` (`imessage` or `sms`) are skipped and reported.
- **Voice on SMS:** `sms` rows that specify a `voice` file are skipped and reported, since voice notes are Signal-only.
- **Empty message:** Rows without a `message` are skipped and reported — except `signal` rows that carry an attachment or a voice note, which are allowed.
- **Missing attachment:** If a named file cannot be found, a warning is printed and the message is sent without the attachment. - **Missing attachment:** If a named file cannot be found, a warning is printed and the message is sent without the attachment.
- **Oversized attachment:** Files larger than 100 MB are rejected with a warning before sending.
- **Transient REST failures:** Rate-limit and server errors (HTTP 429/5xx) and network blips are retried up to 3 times with backoff.
- **Failed sends:** Failed attempts are counted and listed in the summary. - **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. - **Exit codes:** The script exits with `1` if any rows fail, `0` on full success.
@ -366,12 +431,14 @@ After processing all rows, a summary is printed:
================================================== ==================================================
📊 CSV Send Summary 📊 CSV Send Summary
================================================== ==================================================
Total rows: 4 Total rows: 5
✅ Success: 3 ✅ Success: 3
⏭️ Skipped: 1
❌ Failed: 1 ❌ Failed: 1
Errors: Notes:
• Row 2: failed to send • Row 4: invalid/missing SMS service
• Row 5: failed to send
================================================== ==================================================
``` ```
@ -379,18 +446,7 @@ After processing all rows, a summary is printed:
## Changelog ## Changelog
### 4.3.0 See [CHANGELOG.md](CHANGELOG.md) for the full version history.
- **Fixed:** Signal attachments now send correctly. Files are base64-encoded and delivered in the JSON request body via `base64_attachments`; the previous multipart upload was rejected by the Signal REST API with HTTP 400.
- Attachment filename and MIME type are now preserved using a data-URI form.
### 4.2.0
- **Added:** `--show-config` to report resolved settings and their sources (`-v` also checks REST API reachability).
- Config resolution rewritten to correctly source-track values and avoid empty values silently falling through to defaults.
### 4.1.0
- **Fixed:** CSV attachment paths using `~` are now expanded, so Signal/SMS attachments from CSV rows are no longer silently dropped.
- **Fixed (issue #3):** Group messages are detected robustly; raw (non-`group.`) group IDs are no longer mis-sent down the direct-message path.
- **Fixed:** Multipart text/bytes join crash; consistent `+` normalization of account numbers; global `--delay` now applies between rows; missing files are skipped with a warning instead of being passed to the sender; duplicate/dead Signal send branches collapsed.
--- ---

523
sendmsg
View File

@ -5,12 +5,14 @@ sendmsg — Universal message sender for Signal and SMS/iMessage.
Usage: Usage:
sendmsg --signal --to +18005551212 --text "Hello from Signal!" sendmsg --signal --to +18005551212 --text "Hello from Signal!"
sendmsg --signal --recipients group_id_here --text "All members" sendmsg --signal --recipients group_id_here --text "All members"
sendmsg --sms --to +18005551212 --text "Hello via SMS!" sendmsg --sms --to +18005551212 --text "Hello via SMS!" --service sms
sendmsg --sms --to +18005551212 --text "Hey" --file ~/photo.png sendmsg --sms --to +18005551212 --text "Hey" --service imessage --file ~/photo.png
sendmsg --signal --to +18005551212 --text "Multi" --text "more text" sendmsg --signal --to +18005551212 --text "Multi" --text "more text"
sendmsg --signal --to +18005551212 --attach ~/doc.pdf ~/pic.jpg 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 # Bulk send from CSV file
sendmsg --csv messages.csv --delay 2 # Wait N seconds between sends 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 --list-signal # Show linked Signal accounts
sendmsg --link-signal # Link a new device to your existing Signal account sendmsg --link-signal # Link a new device to your existing Signal account
@ -18,7 +20,7 @@ CSV format (messages.csv):
method,recipient,name,message,account,service,file,delay method,recipient,name,message,account,service,file,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,,,~/pic.jpg, sms,+1234567890,Bob,SMS test,,imessage,~/pic.jpg,
signal,+1888222333,Carol,With delay,,,,3 signal,+1888222333,Carol,With delay,,,,3
""" """
@ -29,6 +31,7 @@ import csv
import json import json
import mimetypes import mimetypes
import os import os
import shutil
import subprocess import subprocess
import sys import sys
import time import time
@ -36,12 +39,23 @@ import urllib.error
import urllib.parse import urllib.parse
import urllib.request import urllib.request
VERSION = "4.3.0" VERSION = "5.0.0"
CONFIG_PATHS = [ CONFIG_PATHS = [
os.path.expanduser("~/.sendmsg.conf"), 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(): def load_config():
"""Load settings from sendmsg config file, with env var fallback. """Load settings from sendmsg config file, with env var fallback.
@ -126,6 +140,23 @@ def normalize_account(account):
return 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): def is_group_id(value):
"""Return True if a recipient value looks like a Signal group ID. """Return True if a recipient value looks like a Signal group ID.
@ -140,57 +171,137 @@ def is_group_id(value):
value = value.strip() value = value.strip()
if value.startswith("group."): if value.startswith("group."):
return True return True
# Phone numbers start with '+' followed by digits. Anything else that # Anything that is recognisably a phone number is NOT a group. This now
# is not a phone number is assumed to be a (raw) group ID. # correctly handles bare (no '+') phone numbers, which previously fell
if value.startswith("+") and value[1:].isdigit(): # through and were mis-detected as group IDs.
if looks_like_phone_number(value):
return False return False
return True return True
def send_one_signal(account, recipient, message, group_id=None, attach=None): def normalize_recipient(value):
"""Send a single Signal message. Returns True on success.""" """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) account = normalize_account(account)
payload = {"number": account} target = group_id if group_id else normalize_recipient(recipient)
if group_id: payload = {"number": account, "recipients": [target]}
payload["recipients"] = [group_id]
else:
payload["recipients"] = [recipient]
if message: if message:
payload["message"] = message payload["message"] = message
# Expand '~' / env vars; CSV supplies literal paths the shell never saw. # Normalize attach to a list of valid paths.
attach = expand_path(attach) if attach else None 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)
# If an attachment was specified but does not exist, warn loudly rather voice_path = validate_attachment(voice, as_voice=True) if voice else None
# than silently sending text-only.
if attach and not os.path.isfile(attach):
print(f"⚠️ Warning: attachment not found, sending without it: {attach}", file=sys.stderr)
attach = None
has_attachment = bool(attach) has_attachment = bool(attach_list)
if not message and not has_attachment: has_voice = bool(voice_path)
if not message and not has_attachment and not has_voice:
print( print(
f"❌ Signal send skipped: no message text and no valid attachment " f"❌ Signal send skipped: no message text, attachment, or voice note "
f"(recipient: {group_id or recipient})", f"(recipient: {group_id or recipient})",
file=sys.stderr, file=sys.stderr,
) )
return False 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: if has_attachment:
result = signal_rest_post("/v2/send", payload=payload, files=[attach]) files_payload = {"number": account, "recipients": [target]}
else: if message:
result = signal_rest_post("/v2/send", payload=payload) 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 result is not None return overall_ok
def send_one_sms(to, message, service=None, file=None): def send_one_sms(to, message, service, file=None):
"""Send a single SMS/iMessage. Returns True on success.""" """Send a single SMS/iMessage. Returns True on success.
cmd = ["imsg", "send", "--to", to]
if service: `service` must be 'sms' or 'imessage' (required — no auto-detect).
cmd.extend(["--service", service]) """
cmd = ["imsg", "send", "--to", to, "--service", service]
cmd.extend(["--text", message]) cmd.extend(["--text", message])
if file: if file:
file = expand_path(file) file = expand_path(file)
@ -199,10 +310,18 @@ def send_one_sms(to, message, service=None, file=None):
else: else:
print(f"⚠️ Warning: file not found, skipping attachment: {file}", file=sys.stderr) print(f"⚠️ Warning: file not found, skipping attachment: {file}", file=sys.stderr)
result = subprocess.run(cmd, capture_output=True, text=True) if not shutil.which("imsg"):
print("❌ SMS send failed: 'imsg' CLI not found on PATH.", file=sys.stderr)
return False
try:
result = subprocess.run(cmd, capture_output=True, text=True)
except OSError as e:
print(f"❌ SMS send failed: could not run 'imsg': {e}", file=sys.stderr)
return False
if result.returncode == 0: if result.returncode == 0:
service_label = service.upper() if service else "AUTO" print(f"✅ SMS/iMessage sent to {to} (service: {service.upper()})")
print(f"✅ SMS/iMessage sent to {to} (service: {service_label})")
return True return True
else: else:
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)
@ -247,32 +366,55 @@ def signal_rest_post(endpoint, payload=None, files=None):
body = json.dumps(payload).encode("utf-8") body = json.dumps(payload).encode("utf-8")
headers = {"Content-Type": "application/json"} headers = {"Content-Type": "application/json"}
full_body = body
req = urllib.request.Request(url, data=full_body, headers=headers, method="POST") # Retry transient failures (network blips, 429, 5xx) with backoff.
try: max_attempts = 3
with urllib.request.urlopen(req, timeout=30) as resp: backoff = 2.0
resp_data = resp.read().decode("utf-8") for attempt in range(1, max_attempts + 1):
if resp_data.strip(): req = urllib.request.Request(url, data=body, headers=headers, method="POST")
result = json.loads(resp_data) try:
if isinstance(result, dict) and "error" in result: with urllib.request.urlopen(req, timeout=30) as resp:
print( resp_data = resp.read().decode("utf-8")
f"❌ Signal REST error: {result['error'].strip()}", if resp_data.strip():
file=sys.stderr, result = json.loads(resp_data)
) if isinstance(result, dict) and "error" in result:
return None print(
return result f"❌ Signal REST error: {result['error'].strip()}",
return {} file=sys.stderr,
except urllib.error.HTTPError as e: )
body = e.read().decode("utf-8", errors="replace") return None
print( return result
f"❌ Signal REST error: HTTP {e.code} — {body[:500]}", return {}
file=sys.stderr, except urllib.error.HTTPError as e:
) err_body = e.read().decode("utf-8", errors="replace")
return None # Retry on rate-limit / server errors; fail fast otherwise.
except urllib.error.URLError as e: if e.code in (429, 500, 502, 503, 504) and attempt < max_attempts:
print(f"❌ Cannot reach Signal REST API at {SIGNAL_REST_URL}: {e.reason}", file=sys.stderr) wait = backoff * attempt
return None 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): def signal_rest_get(endpoint):
@ -306,69 +448,40 @@ def cmd_signal(args):
sys.exit(1) sys.exit(1)
attach_files = getattr(args, "attach", None) or getattr(args, "file", None) attach_files = getattr(args, "attach", None) or getattr(args, "file", None)
voice_file = getattr(args, "voice", None)
# Text or an attachment is required in ALL cases, including group sends. # Text, an attachment, or a voice note is required in ALL cases.
if not args.text and not attach_files: if not args.text and not attach_files and not voice_file:
print("❌ Error: --text <message> or --attach <file> is required.", file=sys.stderr) print("❌ Error: --text <message>, --attach <file>, or --voice <audio> is required.", file=sys.stderr)
sys.exit(1) sys.exit(1)
account = normalize_account(args.account or SIGNAL_DEFAULT_ACCOUNT) account = normalize_account(args.account or SIGNAL_DEFAULT_ACCOUNT)
# Build the JSON payload for /v2/send
payload = {"number": account}
if args.recipients: if args.recipients:
# Send to a Signal group — use recipients array with the group ID. recipient = None
payload["recipients"] = [args.recipients] group_id = args.recipients
else: else:
# Send to an individual recipient. The REST API accepts a recipient = args.to[0]
# recipients array for direct messages too; using it consistently group_id = None
# avoids the 1:1-vs-group dispatch mismatch behind issue #3.
payload["recipients"] = [args.to[0]]
# Message text (join multiple --text args) message = "\n".join(args.text) if args.text else None
if args.text:
payload["message"] = "\n".join(args.text)
# Attachments ok = send_one_signal(
if attach_files and len(attach_files) > 0: account=account,
if len(attach_files) == 1: recipient=recipient,
# Single file — send as multipart with the message. message=message,
result = signal_rest_post("/v2/send", payload=payload, files=attach_files) group_id=group_id,
else: attach=attach_files,
# Multiple files: send the text first, then each attachment. voice=voice_file,
multi_payload = {"number": account, "message": payload.get("message", "")} )
multi_payload["recipients"] = payload["recipients"]
# Only send a standalone text message if there is actual text. if ok:
result = None
if multi_payload["message"]:
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, "recipients": payload["recipients"]}
att_result = signal_rest_post("/v2/send", payload=att_payload, files=[fpath])
if att_result is None:
print(f"⚠️ Attachment failed: {fpath}", file=sys.stderr)
else:
result = att_result
else:
# Text-only send (direct or group).
result = signal_rest_post("/v2/send", payload=payload)
if result is not None:
if args.recipients: if args.recipients:
print(f"✅ Signal message sent to group {args.recipients}") print(f"✅ Signal message sent to group {args.recipients}")
elif args.to: elif args.to:
print(f"✅ Signal message sent to {args.to[0]}") print(f"✅ Signal message sent to {normalize_recipient(args.to[0])}")
else: else:
print("✅ Signal message sent") print("✅ Signal message sent")
if args.verbose and isinstance(result, dict):
for key, value in result.items():
if key not in ("success",):
print(f" {key}: {value}")
else: else:
print("❌ Signal message send failed.", file=sys.stderr) print("❌ Signal message send failed.", file=sys.stderr)
sys.exit(1) sys.exit(1)
@ -382,30 +495,33 @@ def cmd_sms(args):
if not args.text: if not args.text:
print("❌ Error: --text <message> is required for SMS.", file=sys.stderr) print("❌ Error: --text <message> is required for SMS.", file=sys.stderr)
sys.exit(1) sys.exit(1)
if not args.service:
print("❌ Error: --service <sms|imessage> 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) msg_text = "\n".join(args.text)
cmd.extend(["--text", msg_text])
attach_files = getattr(args, "file", None) or getattr(args, "attach", None) 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: if attach_files:
for f in attach_files: for f in attach_files:
f = expand_path(f) ef = expand_path(f)
if not os.path.isfile(f): if not os.path.isfile(ef):
print(f"⚠️ Warning: file not found, skipping: {f}", file=sys.stderr) print(f"⚠️ Warning: file not found, skipping: {ef}", file=sys.stderr)
continue continue
cmd.extend(["--file", f]) if chosen_file is None:
chosen_file = ef
else:
print(f"⚠️ Warning: SMS supports one attachment; ignoring extra file: {ef}", file=sys.stderr)
result = subprocess.run(cmd, capture_output=True, text=True) ok = send_one_sms(
if result.returncode == 0: to=args.to[0],
service_label = args.service.upper() if args.service else "AUTO" message=msg_text,
print(f"✅ SMS/iMessage sent to {args.to[0]} (service: {service_label})") service=args.service,
else: file=chosen_file,
print(f"❌ SMS send failed (exit {result.returncode})", file=sys.stderr) )
if result.stderr: if not ok:
print(f" {result.stderr.strip()}", file=sys.stderr)
sys.exit(1) sys.exit(1)
@ -413,7 +529,7 @@ def cmd_csv(args):
"""Bulk send messages from a CSV file. """Bulk send messages from a CSV file.
Expected CSV columns: Expected CSV columns:
method, recipient, name, message, account, service, file, delay method, recipient, name, message, account, service, file, voice, delay
""" """
filepath = args.csv filepath = args.csv
if not os.path.isfile(filepath): if not os.path.isfile(filepath):
@ -432,9 +548,33 @@ def cmd_csv(args):
print("❌ Error: CSV file is empty (no data rows).", file=sys.stderr) print("❌ Error: CSV file is empty (no data rows).", file=sys.stderr)
sys.exit(1) 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) total = len(rows)
success = 0 success = 0
failed = 0 failed = 0
skipped = 0
errors = [] errors = []
for i, row in enumerate(rows, 1): for i, row in enumerate(rows, 1):
@ -442,9 +582,10 @@ def cmd_csv(args):
message = row.get("message", "").strip() message = row.get("message", "").strip()
to = row.get("recipient", "").strip() to = row.get("recipient", "").strip()
name = row.get("name", "").strip() name = row.get("name", "").strip()
voice_path = row.get("voice", "").strip() or None
# Skip blank rows # Skip blank rows
if not method and not message and not to: if not method and not message and not to and not voice_path:
continue continue
if not method: if not method:
@ -456,15 +597,9 @@ def cmd_csv(args):
errors.append(f"Row {i}: unknown method '{method}'") errors.append(f"Row {i}: unknown method '{method}'")
continue 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 # Common fields
account = row.get("account", "").strip() or SIGNAL_DEFAULT_ACCOUNT account = row.get("account", "").strip() or SIGNAL_DEFAULT_ACCOUNT
service = row.get("service", "").strip() or None service = row.get("service", "").strip().lower() or None
file_path = row.get("file", "").strip() or None file_path = row.get("file", "").strip() or None
delay_str = row.get("delay", "").strip() delay_str = row.get("delay", "").strip()
try: try:
@ -473,8 +608,55 @@ def cmd_csv(args):
print(f"⚠️ Row {i}: invalid delay '{delay_str}' — ignoring", file=sys.stderr) print(f"⚠️ Row {i}: invalid delay '{delay_str}' — ignoring", file=sys.stderr)
row_delay = None 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:
print(f"⚠️ Row {i}: empty message — skipping", file=sys.stderr)
skipped += 1
errors.append(f"Row {i}: empty message")
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,
)
failed += 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,
)
failed += 1
errors.append(f"Row {i}: invalid/missing SMS service")
continue
label = f"{name} ({to})" if name else to or "(group)" label = f"{name} ({to})" if name else to or "(group)"
print(f"[{i}/{total}] {method.upper()} → {label}") 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)})")
success += 1
continue
if method == "signal": if method == "signal":
# Detect groups robustly (issue #3): raw base64 group IDs do NOT # Detect groups robustly (issue #3): raw base64 group IDs do NOT
@ -487,10 +669,11 @@ def cmd_csv(args):
message=message, message=message,
group_id=group_id, group_id=group_id,
attach=file_path, attach=file_path,
voice=voice_path,
) )
else: # sms else: # sms
result = send_one_sms( result = send_one_sms(
to=to, to=normalize_account(to),
message=message, message=message,
service=service, service=service,
file=file_path, file=file_path,
@ -504,20 +687,21 @@ def cmd_csv(args):
# Per-row delay overrides global; otherwise use the global --delay. # Per-row delay overrides global; otherwise use the global --delay.
effective_delay = row_delay if row_delay is not None else global_delay effective_delay = row_delay if row_delay is not None else global_delay
if effective_delay > 0: if effective_delay > 0 and not args.dry_run:
time.sleep(effective_delay) time.sleep(effective_delay)
# Summary # Summary
print() print()
print("=" * 50) print("=" * 50)
print("📊 CSV Send Summary") 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}")
print(f" ✅ Success: {success}") print(f" ✅ Success: {success}")
print(f" ⏭️ Skipped: {skipped}")
print(f" ❌ Failed: {failed}") print(f" ❌ Failed: {failed}")
if errors: if errors:
print() print()
print(" Errors:") print(" Notes:")
for e in errors: for e in errors:
print(f" • {e}") print(f" • {e}")
print("=" * 50) print("=" * 50)
@ -597,6 +781,14 @@ def cmd_link_signal(args):
This prints the sgnl:// URL to stdout, then waits for the user to scan/confirm. This prints the sgnl:// URL to stdout, then waits for the user to scan/confirm.
""" """
name = args.name or "automation" 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()
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)
if result is None: if result is None:
@ -619,12 +811,15 @@ 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)
result = signal_rest_get("/v1/accounts") current = signal_rest_get("/v1/accounts")
if result and (isinstance(result, list) and len(result) > 0): if isinstance(current, list):
print(f"✅ Device linked! Account: {result}") current_set = {str(a) for a in current}
return 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. Please try again.", file=sys.stderr) print("⚠️ Linking timed out (no new account detected). Please try again.", file=sys.stderr)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -639,16 +834,18 @@ def main():
"Examples:\n" "Examples:\n"
" %(prog)s --signal --to +18005551212 --text 'Hello!'\n" " %(prog)s --signal --to +18005551212 --text 'Hello!'\n"
" %(prog)s --signal --recipients GROUP_ID --text 'Hello group!'\n" " %(prog)s --signal --recipients GROUP_ID --text 'Hello group!'\n"
" %(prog)s --sms --to +18005551212 --text 'Text via SMS'\n" " %(prog)s --signal --to +18005551212 --voice ~/note.m4a\n"
" %(prog)s --sms --to +18005551212 --text 'Hey' --file ~/pic.jpg\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\n"
" %(prog)s --csv messages.csv --delay 2\n" " %(prog)s --csv messages.csv --delay 2\n"
" %(prog)s --csv messages.csv --dry-run\n"
" %(prog)s --list-signal\n" " %(prog)s --list-signal\n"
" %(prog)s --link-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, 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"
f"Signal REST API: {SIGNAL_REST_URL}\n" f"Signal REST API: {SIGNAL_REST_URL}\n"
"Config file: ~/.sendmsg.conf\n" "Config file: ~/.sendmsg.conf\n"
" [settings]\n" " [settings]\n"
@ -677,6 +874,7 @@ def main():
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", metavar="RECIPIENTS", help="Send to Signal group ID (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')") signal_grp.add_argument("--name", metavar="NAME", help="Device name for Signal linking (default: 'automation')")
# Management commands # Management commands
@ -688,27 +886,46 @@ def main():
# SMS-specific # SMS-specific
sms_grp = parser.add_argument_group("SMS/iMessage options") sms_grp = parser.add_argument_group("SMS/iMessage options")
sms_grp.add_argument("--service", choices=["imessage", "sms", "auto"], help="Force service (default: auto-detect)") sms_grp.add_argument("--service", choices=["imessage", "sms"],
help="Service to use: 'imessage' or 'sms' (required for --sms)")
# CSV-specific # CSV-specific
csv_grp = parser.add_argument_group("CSV options") 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("--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")
args = parser.parse_args() args = parser.parse_args()
# cmd_signal still reads args.attach in places; keep an alias. # cmd_signal still reads args.attach in places; keep an alias.
args.attach = args.file args.attach = args.file
# Validate: must have a method or management command # Validate: must have exactly one method or management command.
if not any([args.signal, args.sms, args.csv, args.list_signal, args.link_signal, args.show_config]): 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() parser.print_help()
print("\n❌ Error: Specify --signal, --sms, --csv, --list-signal, --link-signal, or --show-config", file=sys.stderr) print("\n❌ Error: Specify --signal, --sms, --csv, --list-signal, --link-signal, or --show-config", file=sys.stderr)
sys.exit(1) sys.exit(1)
if sum([bool(args.signal), bool(args.sms), bool(args.csv)]) > 1: if len(chosen) > 1:
print("❌ Error: Choose only one method (--signal, --sms, or --csv).", file=sys.stderr) print(f"❌ Error: Choose only one action (got: {', '.join(chosen)}).", file=sys.stderr)
sys.exit(1) 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 # Set defaults
if args.link_signal and not args.name: if args.link_signal and not args.name:
args.name = "automation" args.name = "automation"