Merge pull request #5 from docwho76/v5.0.0-polish-and-fixes

v5.0.0 polish and fixes, add voice msgs for signal
This commit is contained in:
Daniel Spisak 2026-06-12 23:39:22 -07:00 committed by GitHub
commit ec6312e021
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
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.
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)
- [Usage](#usage)
* [Signal Messages](#signal-messages)
* [Voice Messages](#voice-messages)
* [SMS / iMessage](#sms--imessage)
* [Bulk Send from CSV](#bulk-send-from-csv)
* [Management Commands](#management-commands)
@ -23,6 +24,7 @@ It supports individual direct messages, Signal group broadcasts, file attachment
- [Examples](#examples)
- [Error Handling](#error-handling)
- [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.
@ -63,31 +65,32 @@ It supports individual direct messages, Signal group broadcasts, file attachment
### Required
| Dependency | Version | Purpose |
| ---------- | ----------------- | --------------------------------- |
| Python 3 | 3.9+ | Script runtime |
| macOS 12+ | Monterey or later | Required for iMessage/SMS support |
| Dependency | Version | Purpose |
| ---------- | ------- | -------------- |
| Python 3 | 3.9+ | Script runtime |
> `sendmsg` uses only the Python standard library — no third-party packages to install.
### Signal (optional — only needed for `--signal`)
| Dependency | Version | Purpose |
| ---------------------------- | ----------------------------- | ------------------------------ |
| Docker | 24+ | Container runtime |
| `signal-cli-rest-api` | latest | Signal REST API server |
| `signal-cli` (via container) | latest | Signal protocol implementation |
| Dependency | Version | Purpose |
| ---------------------------- | ------- | ------------------------------ |
| Docker | 24+ | Container runtime |
| `signal-cli-rest-api` | latest | Signal REST API server |
| `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`)
| Dependency | Version | Purpose |
| ---------- | ------- | --------------------------- |
| `imsg` CLI | latest | macOS Messages / SMS bridge |
| Dependency | Version | Purpose |
| ---------- | ----------------- | --------------------------- |
| macOS 12+ | Monterey or later | Native Messages app |
| `imsg` CLI | latest | macOS Messages / SMS bridge |
### System
- **macOS** — The script is designed for macOS; SMS/iMessage uses the native Messages app.
- **Network access** — Required to reach the Signal REST API endpoint.
> SMS/iMessage uses the native macOS Messages app, so macOS 12+ is required
> for the `--sms` path only.
---
@ -158,32 +161,69 @@ sendmsg --signal --to +18885551212 --text "Line 1" --text "Line 2"
# Send multiple attachments
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
> 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
```
# Send via iMessage (auto-detected)
sendmsg --sms --to +18885551212 --text "Hello via iMessage!"
The `--service` flag is **required** and must be either `imessage` or `sms`.
# 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
# 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
```
sendmsg --csv messages.csv # Send all rows
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
```
@ -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:
| Column | Required | Description |
| ----------- | -------- | ----------------------------------------------------------------------------- |
| `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) |
| `name` | No | Display name shown during status output (e.g., "Alice", "Marketing Group") |
| `message` | Yes | The message text to send |
| `account` | No | Signal account phone number (defaults to `$SIGNAL_ACCOUNT` / config value) |
| `service` | No | SMS service: `imessage`, `sms`, or `auto` (SMS only) |
| `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`). |
| Column | Required | Description |
| ----------- | -------- | ------------------------------------------------------------------------------------ |
| `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) |
| `name` | No | Display name shown during status output (e.g., "Alice", "Marketing Group") |
| `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) |
| `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. |
| `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**
- A `message` is required on every row; rows with no message are skipped and reported.
- Group recipients are auto-detected: any `recipient` that is not a `+`-prefixed phone number is treated as a Signal group ID.
- `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`.
- 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.
### Example CSV
```
method,recipient,name,message,account,service,file,delay
signal,+18885551111,Alice,Hello via Signal,+18885551111,,,
signal,group.ZzBHd3NZO...,Team Alert,Morning update for the team,,,,
sms,+18885552222,Bob,SMS test,,,~/pic.jpg,
signal,+18885553333,Carol,With a delay after this row,,,,3
method,recipient,name,message,account,service,file,voice,delay
signal,+18885551111,Alice,Hello via Signal,+18885551111,,,,
signal,group.ZzBHd3NZO...,Team Alert,Morning update for the team,,,,,
signal,+18885552222,Report,,,,~/report.pdf,,
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
@ -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):
```
[1/4] SIGNAL → Alice (+18885551111)
[2/4] SIGNAL → Team Alert (group.ZzBHd3NZO...)
[3/4] SMS → Bob (+18885552222)
[4/4] SIGNAL → Carol (+18885553333)
[1/5] SIGNAL → Alice (+18885551111)
[2/5] SIGNAL → Team Alert (group.ZzBHd3NZO...)
[3/5] SIGNAL → Report (+18885552222)
[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
==================================================
Version: 4.3.0
Version: 5.0.0
Config file: /Users/you/.sendmsg.conf (found)
@ -310,17 +360,17 @@ sendmsg --csv messages.csv
### Daily Broadcast
```
method,recipient,name,message,account,service,file,delay
signal,group.ZzBHd3NZO...,Daily Update,Good morning team! Here's your daily briefing.,+18002222222,,,
method,recipient,name,message,account,service,file,voice,delay
signal,group.ZzBHd3NZO...,Daily Update,Good morning team! Here's your daily briefing.,+18002222222,,,,
```
### Personalized Outreach
```
method,recipient,name,message,account,service,file,delay
signal,+18885551111,John,Hi John hope you're doing well,,,,
sms,+18885552222,Jane,Hey Jane just checking in,,,,
signal,+18885553333,Alex,Alex don't forget the meeting tomorrow at 3pm,,,,
method,recipient,name,message,account,service,file,voice,delay
signal,+18885551111,John,Hi John hope you're doing well,,,,,
sms,+18885552222,Jane,Hey Jane just checking in,,imessage,,,
signal,+18885553333,Alex,Alex don't forget the meeting tomorrow at 3pm,,,,,
```
> 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
```
method,recipient,name,message,account,service,file,delay
signal,+18885551111,John,Here's the report you asked for,+18002222222,,~/Downloads/report.pdf,
sms,+18885552222,Jane,Photo from the event,,,~/Photos/event.jpg,
method,recipient,name,message,account,service,file,voice,delay
signal,+18885551111,John,Here's the report you asked for,+18002222222,,~/Downloads/report.pdf,,
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
```
method,recipient,name,message,account,service,file,delay
signal,+18885551111,Alice,First message,,,,
signal,+18885552222,Bob,Second message after a 3s pause,,,,3
signal,+18885553333,Charlie,Third message,,,,
method,recipient,name,message,account,service,file,voice,delay
signal,+18885551111,Alice,First message,,,,,
signal,+18885552222,Bob,Second message after a 3s pause,,,,,3
signal,+18885553333,Charlie,Third message,,,,,
```
A per-row `delay` value takes precedence over the global `--delay` flag for
@ -351,8 +412,12 @@ that row.
## Error Handling
- **Unknown method:** Rows with invalid `method` values are skipped and reported in the summary.
- **Empty message:** Rows without a `message` are skipped and reported.
- **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.
- **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.
- **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
==================================================
Total rows: 4
Total rows: 5
✅ Success: 3
⏭️ Skipped: 1
❌ Failed: 1
Errors:
• Row 2: failed to send
Notes:
• Row 4: invalid/missing SMS service
• Row 5: failed to send
==================================================
```
@ -379,18 +446,7 @@ After processing all rows, a summary is printed:
## Changelog
### 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.
See [CHANGELOG.md](CHANGELOG.md) for the full version history.
---

523
sendmsg
View File

@ -5,12 +5,14 @@ sendmsg — Universal message sender for Signal and SMS/iMessage.
Usage:
sendmsg --signal --to +18005551212 --text "Hello from Signal!"
sendmsg --signal --recipients group_id_here --text "All members"
sendmsg --sms --to +18005551212 --text "Hello via SMS!"
sendmsg --sms --to +18005551212 --text "Hey" --file ~/photo.png
sendmsg --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
@ -18,7 +20,7 @@ CSV format (messages.csv):
method,recipient,name,message,account,service,file,delay
signal,+1888222333,Alice,Hello via Signal,+1888222333,,,
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
"""
@ -29,6 +31,7 @@ import csv
import json
import mimetypes
import os
import shutil
import subprocess
import sys
import time
@ -36,12 +39,23 @@ import urllib.error
import urllib.parse
import urllib.request
VERSION = "4.3.0"
VERSION = "5.0.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.
@ -126,6 +140,23 @@ def normalize_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.
@ -140,57 +171,137 @@ def is_group_id(value):
value = value.strip()
if value.startswith("group."):
return True
# Phone numbers start with '+' followed by digits. Anything else that
# is not a phone number is assumed to be a (raw) group ID.
if value.startswith("+") and value[1:].isdigit():
# 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 send_one_signal(account, recipient, message, group_id=None, attach=None):
"""Send a single Signal message. Returns True on success."""
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)
payload = {"number": account}
if group_id:
payload["recipients"] = [group_id]
else:
payload["recipients"] = [recipient]
target = group_id if group_id else normalize_recipient(recipient)
payload = {"number": account, "recipients": [target]}
if message:
payload["message"] = message
# Expand '~' / env vars; CSV supplies literal paths the shell never saw.
attach = expand_path(attach) if attach else None
# 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)
# If an attachment was specified but does not exist, warn loudly rather
# 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
voice_path = validate_attachment(voice, as_voice=True) if voice else None
has_attachment = bool(attach)
if not message and not has_attachment:
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 and no valid attachment "
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:
result = signal_rest_post("/v2/send", payload=payload, files=[attach])
else:
result = signal_rest_post("/v2/send", payload=payload)
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 result is not None
return overall_ok
def send_one_sms(to, message, service=None, file=None):
"""Send a single SMS/iMessage. Returns True on success."""
cmd = ["imsg", "send", "--to", to]
if service:
cmd.extend(["--service", service])
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)
@ -199,10 +310,18 @@ def send_one_sms(to, message, service=None, file=None):
else:
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:
service_label = service.upper() if service else "AUTO"
print(f"✅ SMS/iMessage sent to {to} (service: {service_label})")
print(f"✅ SMS/iMessage sent to {to} (service: {service.upper()})")
return True
else:
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")
headers = {"Content-Type": "application/json"}
full_body = body
req = urllib.request.Request(url, data=full_body, headers=headers, method="POST")
try:
with urllib.request.urlopen(req, timeout=30) as resp:
resp_data = resp.read().decode("utf-8")
if resp_data.strip():
result = json.loads(resp_data)
if isinstance(result, dict) and "error" in result:
print(
f"❌ Signal REST error: {result['error'].strip()}",
file=sys.stderr,
)
return None
return result
return {}
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
# 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):
@ -306,69 +448,40 @@ def cmd_signal(args):
sys.exit(1)
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.
if not args.text and not attach_files:
print("❌ Error: --text <message> or --attach <file> is required.", file=sys.stderr)
# 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)
# Build the JSON payload for /v2/send
payload = {"number": account}
if args.recipients:
# Send to a Signal group — use recipients array with the group ID.
payload["recipients"] = [args.recipients]
recipient = None
group_id = args.recipients
else:
# Send to an individual recipient. The REST API accepts a
# recipients array for direct messages too; using it consistently
# avoids the 1:1-vs-group dispatch mismatch behind issue #3.
payload["recipients"] = [args.to[0]]
recipient = args.to[0]
group_id = None
# Message text (join multiple --text args)
if args.text:
payload["message"] = "\n".join(args.text)
message = "\n".join(args.text) if args.text else None
# Attachments
if attach_files and len(attach_files) > 0:
if len(attach_files) == 1:
# Single file — send as multipart with the message.
result = signal_rest_post("/v2/send", payload=payload, files=attach_files)
else:
# Multiple files: send the text first, then each attachment.
multi_payload = {"number": account, "message": payload.get("message", "")}
multi_payload["recipients"] = payload["recipients"]
ok = send_one_signal(
account=account,
recipient=recipient,
message=message,
group_id=group_id,
attach=attach_files,
voice=voice_file,
)
# Only send a standalone text message if there is actual text.
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 ok:
if args.recipients:
print(f"✅ Signal message sent to group {args.recipients}")
elif args.to:
print(f"✅ Signal message sent to {args.to[0]}")
print(f"✅ Signal message sent to {normalize_recipient(args.to[0])}")
else:
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:
print("❌ Signal message send failed.", file=sys.stderr)
sys.exit(1)
@ -382,30 +495,33 @@ def cmd_sms(args):
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)
cmd = ["imsg", "send", "--to", args.to[0]]
if args.service:
cmd.extend(["--service", args.service])
msg_text = "\n".join(args.text)
cmd.extend(["--text", msg_text])
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:
f = expand_path(f)
if not os.path.isfile(f):
print(f"⚠️ Warning: file not found, skipping: {f}", file=sys.stderr)
ef = expand_path(f)
if not os.path.isfile(ef):
print(f"⚠️ Warning: file not found, skipping: {ef}", file=sys.stderr)
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)
if result.returncode == 0:
service_label = args.service.upper() if args.service else "AUTO"
print(f"✅ SMS/iMessage sent to {args.to[0]} (service: {service_label})")
else:
print(f"❌ SMS send failed (exit {result.returncode})", file=sys.stderr)
if result.stderr:
print(f" {result.stderr.strip()}", file=sys.stderr)
ok = send_one_sms(
to=args.to[0],
message=msg_text,
service=args.service,
file=chosen_file,
)
if not ok:
sys.exit(1)
@ -413,7 +529,7 @@ def cmd_csv(args):
"""Bulk send messages from a CSV file.
Expected CSV columns:
method, recipient, name, message, account, service, file, delay
method, recipient, name, message, account, service, file, voice, delay
"""
filepath = args.csv
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)
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
errors = []
for i, row in enumerate(rows, 1):
@ -442,9 +582,10 @@ def cmd_csv(args):
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:
if not method and not message and not to and not voice_path:
continue
if not method:
@ -456,15 +597,9 @@ def cmd_csv(args):
errors.append(f"Row {i}: unknown method '{method}'")
continue
if not message:
print(f"⚠️ Row {i}: empty message — skipping", file=sys.stderr)
failed += 1
errors.append(f"Row {i}: empty message")
continue
# Common fields
account = row.get("account", "").strip() or SIGNAL_DEFAULT_ACCOUNT
service = row.get("service", "").strip() or None
service = row.get("service", "").strip().lower() or None
file_path = row.get("file", "").strip() or None
delay_str = row.get("delay", "").strip()
try:
@ -473,8 +608,55 @@ def cmd_csv(args):
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:
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)"
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":
# Detect groups robustly (issue #3): raw base64 group IDs do NOT
@ -487,10 +669,11 @@ def cmd_csv(args):
message=message,
group_id=group_id,
attach=file_path,
voice=voice_path,
)
else: # sms
result = send_one_sms(
to=to,
to=normalize_account(to),
message=message,
service=service,
file=file_path,
@ -504,20 +687,21 @@ def cmd_csv(args):
# 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:
if effective_delay > 0 and not args.dry_run:
time.sleep(effective_delay)
# Summary
print()
print("=" * 50)
print("📊 CSV Send Summary")
print("📊 CSV Send Summary" + (" (DRY RUN)" if args.dry_run else ""))
print("=" * 50)
print(f" Total rows: {total}")
print(f" ✅ Success: {success}")
print(f" ⏭️ Skipped: {skipped}")
print(f" ❌ Failed: {failed}")
if errors:
print()
print(" Errors:")
print(" Notes:")
for e in errors:
print(f" • {e}")
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.
"""
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}
result = signal_rest_post("/v1/link", payload=payload)
if result is None:
@ -619,12 +811,15 @@ def cmd_link_signal(args):
start = time.time()
while time.time() - start < 120:
time.sleep(3)
result = signal_rest_get("/v1/accounts")
if result and (isinstance(result, list) and len(result) > 0):
print(f"✅ Device linked! Account: {result}")
return
current = signal_rest_get("/v1/accounts")
if isinstance(current, list):
current_set = {str(a) for a in current}
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"
" %(prog)s --signal --to +18005551212 --text 'Hello!'\n"
" %(prog)s --signal --recipients GROUP_ID --text 'Hello group!'\n"
" %(prog)s --sms --to +18005551212 --text 'Text via SMS'\n"
" %(prog)s --sms --to +18005551212 --text 'Hey' --file ~/pic.jpg\n"
" %(prog)s --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\n"
" %(prog)s --link-signal --name 'my-automation'\n"
"\n"
"CSV columns: method, recipient, name, message, account, service, file, delay\n"
"The 'name' field is printed in the status output during CSV sends.\n"
"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"
@ -677,6 +874,7 @@ def main():
signal_grp = parser.add_argument_group("Signal options")
signal_grp.add_argument("--account", metavar="PHONE", help="Signal account phone number to use")
signal_grp.add_argument("--recipients", metavar="RECIPIENTS", help="Send to Signal group ID (no --to needed)")
signal_grp.add_argument("--voice", 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
@ -688,27 +886,46 @@ def main():
# SMS-specific
sms_grp = parser.add_argument_group("SMS/iMessage options")
sms_grp.add_argument("--service", choices=["imessage", "sms", "auto"], help="Force service (default: auto-detect)")
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")
args = parser.parse_args()
# cmd_signal still reads args.attach in places; keep an alias.
args.attach = args.file
# Validate: must have a method or management command
if not any([args.signal, args.sms, args.csv, args.list_signal, args.link_signal, args.show_config]):
# 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 sum([bool(args.signal), bool(args.sms), bool(args.csv)]) > 1:
print("❌ Error: Choose only one method (--signal, --sms, or --csv).", file=sys.stderr)
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"