mirror of
https://github.com/docwho76/sendmsg.git
synced 2026-08-31 01:41:10 -07:00
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:
commit
ec6312e021
43
CHANGELOG.md
Normal file
43
CHANGELOG.md
Normal 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
208
README.md
@ -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
523
sendmsg
@ -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"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user