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.
|
`sendmsg` is a command-line tool for sending messages via the [Signal REST API](https://github.com/bbernhard/signal-cli-rest-api) or locally via macOS [Messages](https://apps.apple.com/app/messages/id1092291483) (iMessage/SMS) through the [`imsg`] CLI.
|
||||||
|
|
||||||
It supports individual direct messages, Signal group broadcasts, file attachments, and bulk sending from CSV files — all with a single, consistent interface.
|
It supports individual direct messages, Signal group broadcasts, file attachments, Signal voice messages, and bulk sending from CSV files — all with a single, consistent interface.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -15,6 +15,7 @@ It supports individual direct messages, Signal group broadcasts, file attachment
|
|||||||
- [Installation](#installation)
|
- [Installation](#installation)
|
||||||
- [Usage](#usage)
|
- [Usage](#usage)
|
||||||
* [Signal Messages](#signal-messages)
|
* [Signal Messages](#signal-messages)
|
||||||
|
* [Voice Messages](#voice-messages)
|
||||||
* [SMS / iMessage](#sms--imessage)
|
* [SMS / iMessage](#sms--imessage)
|
||||||
* [Bulk Send from CSV](#bulk-send-from-csv)
|
* [Bulk Send from CSV](#bulk-send-from-csv)
|
||||||
* [Management Commands](#management-commands)
|
* [Management Commands](#management-commands)
|
||||||
@ -23,6 +24,7 @@ It supports individual direct messages, Signal group broadcasts, file attachment
|
|||||||
- [Examples](#examples)
|
- [Examples](#examples)
|
||||||
- [Error Handling](#error-handling)
|
- [Error Handling](#error-handling)
|
||||||
- [Summary Output](#summary-output)
|
- [Summary Output](#summary-output)
|
||||||
|
- [Changelog](#changelog)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -53,7 +55,7 @@ It supports individual direct messages, Signal group broadcasts, file attachment
|
|||||||
└─────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
**Signal path:** `sendmsg` → JSON HTTP POST → `signal-cli-rest-api` → Docker container → Signal network. Attachments are base64-encoded inline in the JSON request body (the API does not accept multipart uploads).
|
**Signal path:** `sendmsg` → JSON HTTP POST → `signal-cli-rest-api` → Docker container → Signal network. Attachments are base64-encoded inline in the JSON request body (the API does not accept multipart uploads). Voice messages are sent the same way with the API's `voice` flag set so they render as playable voice notes.
|
||||||
|
|
||||||
**SMS/iMessage path:** `sendmsg` → subprocess call → `imsg` CLI → macOS Messages framework → carrier/Apple.
|
**SMS/iMessage path:** `sendmsg` → subprocess call → `imsg` CLI → macOS Messages framework → carrier/Apple.
|
||||||
|
|
||||||
@ -63,31 +65,32 @@ It supports individual direct messages, Signal group broadcasts, file attachment
|
|||||||
|
|
||||||
### Required
|
### Required
|
||||||
|
|
||||||
| Dependency | Version | Purpose |
|
| Dependency | Version | Purpose |
|
||||||
| ---------- | ----------------- | --------------------------------- |
|
| ---------- | ------- | -------------- |
|
||||||
| Python 3 | 3.9+ | Script runtime |
|
| Python 3 | 3.9+ | Script runtime |
|
||||||
| macOS 12+ | Monterey or later | Required for iMessage/SMS support |
|
|
||||||
|
|
||||||
> `sendmsg` uses only the Python standard library — no third-party packages to install.
|
> `sendmsg` uses only the Python standard library — no third-party packages to install.
|
||||||
|
|
||||||
### Signal (optional — only needed for `--signal`)
|
### Signal (optional — only needed for `--signal`)
|
||||||
|
|
||||||
| Dependency | Version | Purpose |
|
| Dependency | Version | Purpose |
|
||||||
| ---------------------------- | ----------------------------- | ------------------------------ |
|
| ---------------------------- | ------- | ------------------------------ |
|
||||||
| Docker | 24+ | Container runtime |
|
| Docker | 24+ | Container runtime |
|
||||||
| `signal-cli-rest-api` | latest | Signal REST API server |
|
| `signal-cli-rest-api` | latest | Signal REST API server |
|
||||||
| `signal-cli` (via container) | latest | Signal protocol implementation |
|
| `signal-cli` (via container) | latest | Signal protocol implementation |
|
||||||
|
|
||||||
|
> Signal sends only need network access to the REST API endpoint — they do
|
||||||
|
> not require macOS.
|
||||||
|
|
||||||
### SMS/iMessage (optional — only needed for `--sms`)
|
### SMS/iMessage (optional — only needed for `--sms`)
|
||||||
|
|
||||||
| Dependency | Version | Purpose |
|
| Dependency | Version | Purpose |
|
||||||
| ---------- | ------- | --------------------------- |
|
| ---------- | ----------------- | --------------------------- |
|
||||||
| `imsg` CLI | latest | macOS Messages / SMS bridge |
|
| macOS 12+ | Monterey or later | Native Messages app |
|
||||||
|
| `imsg` CLI | latest | macOS Messages / SMS bridge |
|
||||||
|
|
||||||
### System
|
> SMS/iMessage uses the native macOS Messages app, so macOS 12+ is required
|
||||||
|
> for the `--sms` path only.
|
||||||
- **macOS** — The script is designed for macOS; SMS/iMessage uses the native Messages app.
|
|
||||||
- **Network access** — Required to reach the Signal REST API endpoint.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -158,32 +161,69 @@ sendmsg --signal --to +18885551212 --text "Line 1" --text "Line 2"
|
|||||||
|
|
||||||
# Send multiple attachments
|
# Send multiple attachments
|
||||||
sendmsg --signal --to +18885551212 --text "Files" --attach ~/doc.pdf ~/pic.jpg
|
sendmsg --signal --to +18885551212 --text "Files" --attach ~/doc.pdf ~/pic.jpg
|
||||||
|
|
||||||
|
# Send an attachment with no text
|
||||||
|
sendmsg --signal --to +18885551212 --attach ~/report.pdf
|
||||||
```
|
```
|
||||||
|
|
||||||
> Attachments are read, base64-encoded, and sent inside the JSON request to
|
> Attachments are read, base64-encoded, and sent inside the JSON request to
|
||||||
> the Signal REST API. The original filename and detected MIME type are
|
> the Signal REST API. The original filename and detected MIME type are
|
||||||
> preserved so recipients see the correct file name and type.
|
> preserved so recipients see the correct file name and type. Attachments
|
||||||
|
> larger than 100 MB are rejected with a warning.
|
||||||
|
|
||||||
|
### Voice Messages
|
||||||
|
|
||||||
|
Send an audio file as a Signal **voice note** (a playable voice message,
|
||||||
|
not a file attachment) with `--voice`:
|
||||||
|
|
||||||
|
```
|
||||||
|
# Send a voice message
|
||||||
|
sendmsg --signal --to +18885551212 --voice ~/note.m4a
|
||||||
|
|
||||||
|
# Send a voice message to a group
|
||||||
|
sendmsg --signal --recipients group.TestGroupHash --voice ~/briefing.ogg
|
||||||
|
|
||||||
|
# Voice message with accompanying text
|
||||||
|
sendmsg --signal --to +18885551212 --voice ~/note.m4a --text "Listen to this"
|
||||||
|
```
|
||||||
|
|
||||||
|
> Recognized audio formats: `.m4a`, `.aac`, `.ogg`, `.opus`, `.mp3`, `.wav`.
|
||||||
|
> A voice note is sent as its own message; if you combine `--voice` with
|
||||||
|
> `--attach`, the voice note and the file attachments are delivered as
|
||||||
|
> separate messages.
|
||||||
|
|
||||||
### SMS / iMessage
|
### SMS / iMessage
|
||||||
|
|
||||||
```
|
The `--service` flag is **required** and must be either `imessage` or `sms`.
|
||||||
# Send via iMessage (auto-detected)
|
|
||||||
sendmsg --sms --to +18885551212 --text "Hello via iMessage!"
|
|
||||||
|
|
||||||
# Force SMS service
|
```
|
||||||
|
# Send via iMessage
|
||||||
|
sendmsg --sms --to +18885551212 --text "Hello via iMessage!" --service imessage
|
||||||
|
|
||||||
|
# Send via SMS
|
||||||
sendmsg --sms --to +18885551212 --text "Hello via SMS!" --service sms
|
sendmsg --sms --to +18885551212 --text "Hello via SMS!" --service sms
|
||||||
|
|
||||||
# Send with an attachment
|
# Send with an attachment
|
||||||
sendmsg --sms --to +18885551212 --text "Photo attached" --file ~/photo.jpg
|
sendmsg --sms --to +18885551212 --text "Photo attached" --service imessage --file ~/photo.jpg
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> SMS/iMessage supports a single attachment per message; if multiple files
|
||||||
|
> are supplied, the first valid one is used and the rest are skipped with a
|
||||||
|
> warning.
|
||||||
|
|
||||||
### Bulk Send from CSV
|
### Bulk Send from CSV
|
||||||
|
|
||||||
```
|
```
|
||||||
sendmsg --csv messages.csv # Send all rows
|
sendmsg --csv messages.csv # Send all rows
|
||||||
sendmsg --csv messages.csv --delay 2 # Wait 2 seconds between every send
|
sendmsg --csv messages.csv --delay 2 # Wait 2 seconds between every send
|
||||||
|
sendmsg --csv messages.csv --dry-run # Preview every row without sending
|
||||||
|
sendmsg --csv messages.csv --yes # Skip the large-batch confirmation
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> Batches of more than 50 rows prompt for confirmation before sending. Use
|
||||||
|
> `--yes` / `-y` to skip the prompt for unattended runs, or `--dry-run` to
|
||||||
|
> preview exactly what would be sent first.
|
||||||
|
|
||||||
### Management Commands
|
### Management Commands
|
||||||
|
|
||||||
```
|
```
|
||||||
@ -205,31 +245,38 @@ sendmsg --show-config -v # also checks whether the Signal REST API is reachabl
|
|||||||
|
|
||||||
Create a CSV file with the following columns:
|
Create a CSV file with the following columns:
|
||||||
|
|
||||||
| Column | Required | Description |
|
| Column | Required | Description |
|
||||||
| ----------- | -------- | ----------------------------------------------------------------------------- |
|
| ----------- | -------- | ------------------------------------------------------------------------------------ |
|
||||||
| `method` | Yes | `signal` or `sms` (defaults to `signal` if left blank) |
|
| `method` | Yes | `signal` or `sms` (defaults to `signal` if left blank) |
|
||||||
| `recipient` | Yes | Phone number, or a Signal group ID (`group.XXXX` or a raw group key) |
|
| `recipient` | Yes | Phone number, or a Signal group ID (`group.XXXX` or a raw group key) |
|
||||||
| `name` | No | Display name shown during status output (e.g., "Alice", "Marketing Group") |
|
| `name` | No | Display name shown during status output (e.g., "Alice", "Marketing Group") |
|
||||||
| `message` | Yes | The message text to send |
|
| `message` | Varies | Message text. Required for `sms` rows and for `signal` rows without a `file`/`voice`. |
|
||||||
| `account` | No | Signal account phone number (defaults to `$SIGNAL_ACCOUNT` / config value) |
|
| `account` | No | Signal account phone number (defaults to `$SIGNAL_ACCOUNT` / config value) |
|
||||||
| `service` | No | SMS service: `imessage`, `sms`, or `auto` (SMS only) |
|
| `service` | Varies | SMS service: `imessage` or `sms`. **Required** on `sms` rows; ignored for `signal`. |
|
||||||
| `file` | No | Path to an attachment file. `~` and environment variables are expanded. |
|
| `file` | No | Path to an attachment file. `~` and environment variables are expanded. |
|
||||||
| `delay` | No | Seconds to wait after this row is sent (overrides the global `--delay`). |
|
| `voice` | No | Path to an audio file to send as a Signal **voice note**. Signal only. |
|
||||||
|
| `delay` | No | Seconds to wait after this row is sent (overrides the global `--delay`). |
|
||||||
|
|
||||||
**Notes**
|
**Notes**
|
||||||
|
|
||||||
- A `message` is required on every row; rows with no message are skipped and reported.
|
- `signal` rows may be **attachment-only** or **voice-only**: if a `file` or `voice` is present, the `message` may be left blank. `sms` rows always require a `message`.
|
||||||
- Group recipients are auto-detected: any `recipient` that is not a `+`-prefixed phone number is treated as a Signal group ID.
|
- The `voice` column is **Signal only**. An `sms` row with a `voice` value is skipped and reported, since SMS/iMessage has no voice-note concept.
|
||||||
|
- A voice note is sent as its own message; if a row has both `voice` and `file`, they are delivered as separate messages.
|
||||||
|
- Recognized voice formats: `.m4a`, `.aac`, `.ogg`, `.opus`, `.mp3`, `.wav`.
|
||||||
|
- `sms` rows require a `service` of `imessage` or `sms`; rows with a missing or invalid service are skipped and reported.
|
||||||
|
- Group recipients are auto-detected: any `recipient` that is not a phone number is treated as a Signal group ID. Bare (no `+`) phone numbers are recognized as numbers, not groups.
|
||||||
- File paths starting with `~` are expanded to the home directory of the user running the script. If a named attachment cannot be found, a warning is printed and the message is still sent without it.
|
- File paths starting with `~` are expanded to the home directory of the user running the script. If a named attachment cannot be found, a warning is printed and the message is still sent without it.
|
||||||
|
|
||||||
### Example CSV
|
### Example CSV
|
||||||
|
|
||||||
```
|
```
|
||||||
method,recipient,name,message,account,service,file,delay
|
method,recipient,name,message,account,service,file,voice,delay
|
||||||
signal,+18885551111,Alice,Hello via Signal,+18885551111,,,
|
signal,+18885551111,Alice,Hello via Signal,+18885551111,,,,
|
||||||
signal,group.ZzBHd3NZO...,Team Alert,Morning update for the team,,,,
|
signal,group.ZzBHd3NZO...,Team Alert,Morning update for the team,,,,,
|
||||||
sms,+18885552222,Bob,SMS test,,,~/pic.jpg,
|
signal,+18885552222,Report,,,,~/report.pdf,,
|
||||||
signal,+18885553333,Carol,With a delay after this row,,,,3
|
signal,+18885556666,Briefing,,,,,~/briefing.m4a,
|
||||||
|
sms,+18885553333,Bob,SMS test,,imessage,~/pic.jpg,,
|
||||||
|
signal,+18885554444,Carol,With a delay after this row,,,,,3
|
||||||
```
|
```
|
||||||
|
|
||||||
### Status Output
|
### Status Output
|
||||||
@ -237,13 +284,16 @@ signal,+18885553333,Carol,With a delay after this row,,,,3
|
|||||||
During a bulk send, each row prints its name (if provided):
|
During a bulk send, each row prints its name (if provided):
|
||||||
|
|
||||||
```
|
```
|
||||||
[1/4] SIGNAL → Alice (+18885551111)
|
[1/5] SIGNAL → Alice (+18885551111)
|
||||||
[2/4] SIGNAL → Team Alert (group.ZzBHd3NZO...)
|
[2/5] SIGNAL → Team Alert (group.ZzBHd3NZO...)
|
||||||
[3/4] SMS → Bob (+18885552222)
|
[3/5] SIGNAL → Report (+18885552222)
|
||||||
[4/4] SIGNAL → Carol (+18885553333)
|
[4/5] SMS → Bob (+18885553333)
|
||||||
|
[5/5] SIGNAL → Carol (+18885554444)
|
||||||
```
|
```
|
||||||
|
|
||||||
If no `name` is provided, the recipient is shown instead.
|
If no `name` is provided, the recipient is shown instead. A `--dry-run`
|
||||||
|
prefixes each line with `[DRY]` and notes group/file/service details
|
||||||
|
without sending.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -279,7 +329,7 @@ $ sendmsg --show-config
|
|||||||
==================================================
|
==================================================
|
||||||
⚙️ sendmsg configuration
|
⚙️ sendmsg configuration
|
||||||
==================================================
|
==================================================
|
||||||
Version: 4.3.0
|
Version: 5.0.0
|
||||||
|
|
||||||
Config file: /Users/you/.sendmsg.conf (found)
|
Config file: /Users/you/.sendmsg.conf (found)
|
||||||
|
|
||||||
@ -310,17 +360,17 @@ sendmsg --csv messages.csv
|
|||||||
### Daily Broadcast
|
### Daily Broadcast
|
||||||
|
|
||||||
```
|
```
|
||||||
method,recipient,name,message,account,service,file,delay
|
method,recipient,name,message,account,service,file,voice,delay
|
||||||
signal,group.ZzBHd3NZO...,Daily Update,Good morning team! Here's your daily briefing.,+18002222222,,,
|
signal,group.ZzBHd3NZO...,Daily Update,Good morning team! Here's your daily briefing.,+18002222222,,,,
|
||||||
```
|
```
|
||||||
|
|
||||||
### Personalized Outreach
|
### Personalized Outreach
|
||||||
|
|
||||||
```
|
```
|
||||||
method,recipient,name,message,account,service,file,delay
|
method,recipient,name,message,account,service,file,voice,delay
|
||||||
signal,+18885551111,John,Hi John hope you're doing well,,,,
|
signal,+18885551111,John,Hi John hope you're doing well,,,,,
|
||||||
sms,+18885552222,Jane,Hey Jane just checking in,,,,
|
sms,+18885552222,Jane,Hey Jane just checking in,,imessage,,,
|
||||||
signal,+18885553333,Alex,Alex don't forget the meeting tomorrow at 3pm,,,,
|
signal,+18885553333,Alex,Alex don't forget the meeting tomorrow at 3pm,,,,,
|
||||||
```
|
```
|
||||||
|
|
||||||
> Avoid commas inside the `message` field unless the field is quoted, since
|
> Avoid commas inside the `message` field unless the field is quoted, since
|
||||||
@ -329,18 +379,29 @@ signal,+18885553333,Alex,Alex don't forget the meeting tomorrow at 3pm,,,,
|
|||||||
### With Attachments
|
### With Attachments
|
||||||
|
|
||||||
```
|
```
|
||||||
method,recipient,name,message,account,service,file,delay
|
method,recipient,name,message,account,service,file,voice,delay
|
||||||
signal,+18885551111,John,Here's the report you asked for,+18002222222,,~/Downloads/report.pdf,
|
signal,+18885551111,John,Here's the report you asked for,+18002222222,,~/Downloads/report.pdf,,
|
||||||
sms,+18885552222,Jane,Photo from the event,,,~/Photos/event.jpg,
|
sms,+18885552222,Jane,Photo from the event,,imessage,~/Photos/event.jpg,,
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### With Voice Messages
|
||||||
|
|
||||||
|
```
|
||||||
|
method,recipient,name,message,account,service,file,voice,delay
|
||||||
|
signal,+18885551111,John,Listen to this update,,,,~/recordings/update.m4a,
|
||||||
|
signal,group.ZzBHd3NZO...,Team,,,,,~/recordings/standup.ogg,
|
||||||
|
```
|
||||||
|
|
||||||
|
> The `voice` column is Signal only and may be used with or without
|
||||||
|
> `message` text.
|
||||||
|
|
||||||
### With Delays
|
### With Delays
|
||||||
|
|
||||||
```
|
```
|
||||||
method,recipient,name,message,account,service,file,delay
|
method,recipient,name,message,account,service,file,voice,delay
|
||||||
signal,+18885551111,Alice,First message,,,,
|
signal,+18885551111,Alice,First message,,,,,
|
||||||
signal,+18885552222,Bob,Second message after a 3s pause,,,,3
|
signal,+18885552222,Bob,Second message after a 3s pause,,,,,3
|
||||||
signal,+18885553333,Charlie,Third message,,,,
|
signal,+18885553333,Charlie,Third message,,,,,
|
||||||
```
|
```
|
||||||
|
|
||||||
A per-row `delay` value takes precedence over the global `--delay` flag for
|
A per-row `delay` value takes precedence over the global `--delay` flag for
|
||||||
@ -351,8 +412,12 @@ that row.
|
|||||||
## Error Handling
|
## Error Handling
|
||||||
|
|
||||||
- **Unknown method:** Rows with invalid `method` values are skipped and reported in the summary.
|
- **Unknown method:** Rows with invalid `method` values are skipped and reported in the summary.
|
||||||
- **Empty message:** Rows without a `message` are skipped and reported.
|
- **Missing SMS service:** `sms` rows without a valid `service` (`imessage` or `sms`) are skipped and reported.
|
||||||
|
- **Voice on SMS:** `sms` rows that specify a `voice` file are skipped and reported, since voice notes are Signal-only.
|
||||||
|
- **Empty message:** Rows without a `message` are skipped and reported — except `signal` rows that carry an attachment or a voice note, which are allowed.
|
||||||
- **Missing attachment:** If a named file cannot be found, a warning is printed and the message is sent without the attachment.
|
- **Missing attachment:** If a named file cannot be found, a warning is printed and the message is sent without the attachment.
|
||||||
|
- **Oversized attachment:** Files larger than 100 MB are rejected with a warning before sending.
|
||||||
|
- **Transient REST failures:** Rate-limit and server errors (HTTP 429/5xx) and network blips are retried up to 3 times with backoff.
|
||||||
- **Failed sends:** Failed attempts are counted and listed in the summary.
|
- **Failed sends:** Failed attempts are counted and listed in the summary.
|
||||||
- **Exit codes:** The script exits with `1` if any rows fail, `0` on full success.
|
- **Exit codes:** The script exits with `1` if any rows fail, `0` on full success.
|
||||||
|
|
||||||
@ -366,12 +431,14 @@ After processing all rows, a summary is printed:
|
|||||||
==================================================
|
==================================================
|
||||||
📊 CSV Send Summary
|
📊 CSV Send Summary
|
||||||
==================================================
|
==================================================
|
||||||
Total rows: 4
|
Total rows: 5
|
||||||
✅ Success: 3
|
✅ Success: 3
|
||||||
|
⏭️ Skipped: 1
|
||||||
❌ Failed: 1
|
❌ Failed: 1
|
||||||
|
|
||||||
Errors:
|
Notes:
|
||||||
• Row 2: failed to send
|
• Row 4: invalid/missing SMS service
|
||||||
|
• Row 5: failed to send
|
||||||
==================================================
|
==================================================
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -379,18 +446,7 @@ After processing all rows, a summary is printed:
|
|||||||
|
|
||||||
## Changelog
|
## Changelog
|
||||||
|
|
||||||
### 4.3.0
|
See [CHANGELOG.md](CHANGELOG.md) for the full version history.
|
||||||
- **Fixed:** Signal attachments now send correctly. Files are base64-encoded and delivered in the JSON request body via `base64_attachments`; the previous multipart upload was rejected by the Signal REST API with HTTP 400.
|
|
||||||
- Attachment filename and MIME type are now preserved using a data-URI form.
|
|
||||||
|
|
||||||
### 4.2.0
|
|
||||||
- **Added:** `--show-config` to report resolved settings and their sources (`-v` also checks REST API reachability).
|
|
||||||
- Config resolution rewritten to correctly source-track values and avoid empty values silently falling through to defaults.
|
|
||||||
|
|
||||||
### 4.1.0
|
|
||||||
- **Fixed:** CSV attachment paths using `~` are now expanded, so Signal/SMS attachments from CSV rows are no longer silently dropped.
|
|
||||||
- **Fixed (issue #3):** Group messages are detected robustly; raw (non-`group.`) group IDs are no longer mis-sent down the direct-message path.
|
|
||||||
- **Fixed:** Multipart text/bytes join crash; consistent `+` normalization of account numbers; global `--delay` now applies between rows; missing files are skipped with a warning instead of being passed to the sender; duplicate/dead Signal send branches collapsed.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
523
sendmsg
523
sendmsg
@ -5,12 +5,14 @@ sendmsg — Universal message sender for Signal and SMS/iMessage.
|
|||||||
Usage:
|
Usage:
|
||||||
sendmsg --signal --to +18005551212 --text "Hello from Signal!"
|
sendmsg --signal --to +18005551212 --text "Hello from Signal!"
|
||||||
sendmsg --signal --recipients group_id_here --text "All members"
|
sendmsg --signal --recipients group_id_here --text "All members"
|
||||||
sendmsg --sms --to +18005551212 --text "Hello via SMS!"
|
sendmsg --sms --to +18005551212 --text "Hello via SMS!" --service sms
|
||||||
sendmsg --sms --to +18005551212 --text "Hey" --file ~/photo.png
|
sendmsg --sms --to +18005551212 --text "Hey" --service imessage --file ~/photo.png
|
||||||
sendmsg --signal --to +18005551212 --text "Multi" --text "more text"
|
sendmsg --signal --to +18005551212 --text "Multi" --text "more text"
|
||||||
sendmsg --signal --to +18005551212 --attach ~/doc.pdf ~/pic.jpg
|
sendmsg --signal --to +18005551212 --attach ~/doc.pdf ~/pic.jpg
|
||||||
|
sendmsg --signal --to +18005551212 --voice ~/note.m4a # Send a voice message
|
||||||
sendmsg --csv messages.csv # Bulk send from CSV file
|
sendmsg --csv messages.csv # Bulk send from CSV file
|
||||||
sendmsg --csv messages.csv --delay 2 # Wait N seconds between sends
|
sendmsg --csv messages.csv --delay 2 # Wait N seconds between sends
|
||||||
|
sendmsg --csv messages.csv --dry-run # Preview without sending
|
||||||
sendmsg --list-signal # Show linked Signal accounts
|
sendmsg --list-signal # Show linked Signal accounts
|
||||||
sendmsg --link-signal # Link a new device to your existing Signal account
|
sendmsg --link-signal # Link a new device to your existing Signal account
|
||||||
|
|
||||||
@ -18,7 +20,7 @@ CSV format (messages.csv):
|
|||||||
method,recipient,name,message,account,service,file,delay
|
method,recipient,name,message,account,service,file,delay
|
||||||
signal,+1888222333,Alice,Hello via Signal,+1888222333,,,
|
signal,+1888222333,Alice,Hello via Signal,+1888222333,,,
|
||||||
signal,group.ZzBHd3NZO...,Group alert,Hi team,,,,
|
signal,group.ZzBHd3NZO...,Group alert,Hi team,,,,
|
||||||
sms,+1234567890,Bob,SMS test,,,~/pic.jpg,
|
sms,+1234567890,Bob,SMS test,,imessage,~/pic.jpg,
|
||||||
signal,+1888222333,Carol,With delay,,,,3
|
signal,+1888222333,Carol,With delay,,,,3
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@ -29,6 +31,7 @@ import csv
|
|||||||
import json
|
import json
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
@ -36,12 +39,23 @@ import urllib.error
|
|||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
VERSION = "4.3.0"
|
VERSION = "5.0.0"
|
||||||
|
|
||||||
CONFIG_PATHS = [
|
CONFIG_PATHS = [
|
||||||
os.path.expanduser("~/.sendmsg.conf"),
|
os.path.expanduser("~/.sendmsg.conf"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Maximum attachment size accepted before base64-encoding into the JSON body.
|
||||||
|
# Large files balloon memory and request size; the REST API also rejects them.
|
||||||
|
MAX_ATTACHMENT_BYTES = 100 * 1024 * 1024 # 100 MB
|
||||||
|
|
||||||
|
# Number of rows in a CSV batch above which we ask for confirmation,
|
||||||
|
# unless --yes is supplied.
|
||||||
|
CSV_CONFIRM_THRESHOLD = 50
|
||||||
|
|
||||||
|
# Audio MIME types that signal-cli will accept as a voice note.
|
||||||
|
VOICE_AUDIO_EXTS = {".m4a", ".aac", ".ogg", ".opus", ".mp3", ".wav"}
|
||||||
|
|
||||||
|
|
||||||
def load_config():
|
def load_config():
|
||||||
"""Load settings from sendmsg config file, with env var fallback.
|
"""Load settings from sendmsg config file, with env var fallback.
|
||||||
@ -126,6 +140,23 @@ def normalize_account(account):
|
|||||||
return account
|
return account
|
||||||
|
|
||||||
|
|
||||||
|
def looks_like_phone_number(value):
|
||||||
|
"""Return True if value looks like a phone number.
|
||||||
|
|
||||||
|
Accepts numbers with an explicit '+', and also bare all-digit strings
|
||||||
|
(which a user may type without the leading '+'). This is the inverse
|
||||||
|
test used by is_group_id, and the two must agree on what a bare number
|
||||||
|
is so a phone number is never mis-routed as a group.
|
||||||
|
"""
|
||||||
|
if not value:
|
||||||
|
return False
|
||||||
|
value = value.strip()
|
||||||
|
if value.startswith("+"):
|
||||||
|
return value[1:].isdigit() and len(value) > 1
|
||||||
|
# A bare run of digits (optionally long) is a phone number, not a group.
|
||||||
|
return value.isdigit()
|
||||||
|
|
||||||
|
|
||||||
def is_group_id(value):
|
def is_group_id(value):
|
||||||
"""Return True if a recipient value looks like a Signal group ID.
|
"""Return True if a recipient value looks like a Signal group ID.
|
||||||
|
|
||||||
@ -140,57 +171,137 @@ def is_group_id(value):
|
|||||||
value = value.strip()
|
value = value.strip()
|
||||||
if value.startswith("group."):
|
if value.startswith("group."):
|
||||||
return True
|
return True
|
||||||
# Phone numbers start with '+' followed by digits. Anything else that
|
# Anything that is recognisably a phone number is NOT a group. This now
|
||||||
# is not a phone number is assumed to be a (raw) group ID.
|
# correctly handles bare (no '+') phone numbers, which previously fell
|
||||||
if value.startswith("+") and value[1:].isdigit():
|
# through and were mis-detected as group IDs.
|
||||||
|
if looks_like_phone_number(value):
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def send_one_signal(account, recipient, message, group_id=None, attach=None):
|
def normalize_recipient(value):
|
||||||
"""Send a single Signal message. Returns True on success."""
|
"""Normalize a recipient for the Signal recipients[] array.
|
||||||
|
|
||||||
|
Phone numbers get a leading '+'; group IDs are passed through unchanged.
|
||||||
|
"""
|
||||||
|
if not value:
|
||||||
|
return value
|
||||||
|
value = value.strip()
|
||||||
|
if is_group_id(value):
|
||||||
|
return value
|
||||||
|
return normalize_account(value)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_attachment(path, *, as_voice=False):
|
||||||
|
"""Validate an attachment path.
|
||||||
|
|
||||||
|
Returns the expanded path if valid, otherwise None (with a warning).
|
||||||
|
"""
|
||||||
|
if not path:
|
||||||
|
return None
|
||||||
|
path = expand_path(path)
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
print(f"⚠️ Warning: attachment not found: {path}", file=sys.stderr)
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
size = os.path.getsize(path)
|
||||||
|
except OSError as e:
|
||||||
|
print(f"⚠️ Warning: cannot read attachment {path}: {e}", file=sys.stderr)
|
||||||
|
return None
|
||||||
|
if size > MAX_ATTACHMENT_BYTES:
|
||||||
|
print(
|
||||||
|
f"⚠️ Warning: attachment too large "
|
||||||
|
f"({size / 1024 / 1024:.1f} MB > "
|
||||||
|
f"{MAX_ATTACHMENT_BYTES / 1024 / 1024:.0f} MB limit): {path}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
if as_voice:
|
||||||
|
ext = os.path.splitext(path)[1].lower()
|
||||||
|
if ext not in VOICE_AUDIO_EXTS:
|
||||||
|
print(
|
||||||
|
f"⚠️ Warning: '{ext}' is not a recognized voice/audio format "
|
||||||
|
f"(expected one of {', '.join(sorted(VOICE_AUDIO_EXTS))}); "
|
||||||
|
f"sending anyway.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def send_one_signal(account, recipient, message, group_id=None,
|
||||||
|
attach=None, voice=None):
|
||||||
|
"""Send a single Signal message. Returns True on success.
|
||||||
|
|
||||||
|
`attach` may be a single path or a list of paths.
|
||||||
|
`voice` is a single audio path to be delivered as a voice note.
|
||||||
|
"""
|
||||||
account = normalize_account(account)
|
account = normalize_account(account)
|
||||||
|
|
||||||
payload = {"number": account}
|
target = group_id if group_id else normalize_recipient(recipient)
|
||||||
if group_id:
|
payload = {"number": account, "recipients": [target]}
|
||||||
payload["recipients"] = [group_id]
|
|
||||||
else:
|
|
||||||
payload["recipients"] = [recipient]
|
|
||||||
|
|
||||||
if message:
|
if message:
|
||||||
payload["message"] = message
|
payload["message"] = message
|
||||||
|
|
||||||
# Expand '~' / env vars; CSV supplies literal paths the shell never saw.
|
# Normalize attach to a list of valid paths.
|
||||||
attach = expand_path(attach) if attach else None
|
attach_list = []
|
||||||
|
if attach:
|
||||||
|
candidates = attach if isinstance(attach, (list, tuple)) else [attach]
|
||||||
|
for c in candidates:
|
||||||
|
valid = validate_attachment(c)
|
||||||
|
if valid:
|
||||||
|
attach_list.append(valid)
|
||||||
|
|
||||||
# If an attachment was specified but does not exist, warn loudly rather
|
voice_path = validate_attachment(voice, as_voice=True) if voice else None
|
||||||
# than silently sending text-only.
|
|
||||||
if attach and not os.path.isfile(attach):
|
|
||||||
print(f"⚠️ Warning: attachment not found, sending without it: {attach}", file=sys.stderr)
|
|
||||||
attach = None
|
|
||||||
|
|
||||||
has_attachment = bool(attach)
|
has_attachment = bool(attach_list)
|
||||||
if not message and not has_attachment:
|
has_voice = bool(voice_path)
|
||||||
|
|
||||||
|
if not message and not has_attachment and not has_voice:
|
||||||
print(
|
print(
|
||||||
f"❌ Signal send skipped: no message text and no valid attachment "
|
f"❌ Signal send skipped: no message text, attachment, or voice note "
|
||||||
f"(recipient: {group_id or recipient})",
|
f"(recipient: {group_id or recipient})",
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# A voice note is sent as its own message (a voice note cannot be mixed
|
||||||
|
# with arbitrary file attachments in a single send).
|
||||||
|
overall_ok = True
|
||||||
|
|
||||||
|
if has_voice:
|
||||||
|
voice_payload = {"number": account, "recipients": [target], "voice": True}
|
||||||
|
if message and not has_attachment:
|
||||||
|
# Attach the text to the voice message if there are no other files.
|
||||||
|
voice_payload["message"] = message
|
||||||
|
vres = signal_rest_post("/v2/send", payload=voice_payload, files=[voice_path])
|
||||||
|
overall_ok = overall_ok and (vres is not None)
|
||||||
|
# If text rode along with the voice note, don't send it again below.
|
||||||
|
if has_attachment and message:
|
||||||
|
pass # text will be sent with the attachments below
|
||||||
|
else:
|
||||||
|
message = None # consumed
|
||||||
|
|
||||||
if has_attachment:
|
if has_attachment:
|
||||||
result = signal_rest_post("/v2/send", payload=payload, files=[attach])
|
files_payload = {"number": account, "recipients": [target]}
|
||||||
else:
|
if message:
|
||||||
result = signal_rest_post("/v2/send", payload=payload)
|
files_payload["message"] = message
|
||||||
|
ares = signal_rest_post("/v2/send", payload=files_payload, files=attach_list)
|
||||||
|
overall_ok = overall_ok and (ares is not None)
|
||||||
|
elif message and not has_voice:
|
||||||
|
# Pure text-only send.
|
||||||
|
tres = signal_rest_post("/v2/send", payload=payload)
|
||||||
|
overall_ok = overall_ok and (tres is not None)
|
||||||
|
|
||||||
return result is not None
|
return overall_ok
|
||||||
|
|
||||||
|
|
||||||
def send_one_sms(to, message, service=None, file=None):
|
def send_one_sms(to, message, service, file=None):
|
||||||
"""Send a single SMS/iMessage. Returns True on success."""
|
"""Send a single SMS/iMessage. Returns True on success.
|
||||||
cmd = ["imsg", "send", "--to", to]
|
|
||||||
if service:
|
`service` must be 'sms' or 'imessage' (required — no auto-detect).
|
||||||
cmd.extend(["--service", service])
|
"""
|
||||||
|
cmd = ["imsg", "send", "--to", to, "--service", service]
|
||||||
cmd.extend(["--text", message])
|
cmd.extend(["--text", message])
|
||||||
if file:
|
if file:
|
||||||
file = expand_path(file)
|
file = expand_path(file)
|
||||||
@ -199,10 +310,18 @@ def send_one_sms(to, message, service=None, file=None):
|
|||||||
else:
|
else:
|
||||||
print(f"⚠️ Warning: file not found, skipping attachment: {file}", file=sys.stderr)
|
print(f"⚠️ Warning: file not found, skipping attachment: {file}", file=sys.stderr)
|
||||||
|
|
||||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
if not shutil.which("imsg"):
|
||||||
|
print("❌ SMS send failed: 'imsg' CLI not found on PATH.", file=sys.stderr)
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
except OSError as e:
|
||||||
|
print(f"❌ SMS send failed: could not run 'imsg': {e}", file=sys.stderr)
|
||||||
|
return False
|
||||||
|
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
service_label = service.upper() if service else "AUTO"
|
print(f"✅ SMS/iMessage sent to {to} (service: {service.upper()})")
|
||||||
print(f"✅ SMS/iMessage sent to {to} (service: {service_label})")
|
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
print(f"❌ SMS send failed (exit {result.returncode}): {result.stderr.strip()}", file=sys.stderr)
|
print(f"❌ SMS send failed (exit {result.returncode}): {result.stderr.strip()}", file=sys.stderr)
|
||||||
@ -247,32 +366,55 @@ def signal_rest_post(endpoint, payload=None, files=None):
|
|||||||
|
|
||||||
body = json.dumps(payload).encode("utf-8")
|
body = json.dumps(payload).encode("utf-8")
|
||||||
headers = {"Content-Type": "application/json"}
|
headers = {"Content-Type": "application/json"}
|
||||||
full_body = body
|
|
||||||
|
|
||||||
req = urllib.request.Request(url, data=full_body, headers=headers, method="POST")
|
# Retry transient failures (network blips, 429, 5xx) with backoff.
|
||||||
try:
|
max_attempts = 3
|
||||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
backoff = 2.0
|
||||||
resp_data = resp.read().decode("utf-8")
|
for attempt in range(1, max_attempts + 1):
|
||||||
if resp_data.strip():
|
req = urllib.request.Request(url, data=body, headers=headers, method="POST")
|
||||||
result = json.loads(resp_data)
|
try:
|
||||||
if isinstance(result, dict) and "error" in result:
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
print(
|
resp_data = resp.read().decode("utf-8")
|
||||||
f"❌ Signal REST error: {result['error'].strip()}",
|
if resp_data.strip():
|
||||||
file=sys.stderr,
|
result = json.loads(resp_data)
|
||||||
)
|
if isinstance(result, dict) and "error" in result:
|
||||||
return None
|
print(
|
||||||
return result
|
f"❌ Signal REST error: {result['error'].strip()}",
|
||||||
return {}
|
file=sys.stderr,
|
||||||
except urllib.error.HTTPError as e:
|
)
|
||||||
body = e.read().decode("utf-8", errors="replace")
|
return None
|
||||||
print(
|
return result
|
||||||
f"❌ Signal REST error: HTTP {e.code} — {body[:500]}",
|
return {}
|
||||||
file=sys.stderr,
|
except urllib.error.HTTPError as e:
|
||||||
)
|
err_body = e.read().decode("utf-8", errors="replace")
|
||||||
return None
|
# Retry on rate-limit / server errors; fail fast otherwise.
|
||||||
except urllib.error.URLError as e:
|
if e.code in (429, 500, 502, 503, 504) and attempt < max_attempts:
|
||||||
print(f"❌ Cannot reach Signal REST API at {SIGNAL_REST_URL}: {e.reason}", file=sys.stderr)
|
wait = backoff * attempt
|
||||||
return None
|
print(
|
||||||
|
f"⚠️ Signal REST HTTP {e.code}; retrying in {wait:.0f}s "
|
||||||
|
f"(attempt {attempt}/{max_attempts})...",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
time.sleep(wait)
|
||||||
|
continue
|
||||||
|
print(
|
||||||
|
f"❌ Signal REST error: HTTP {e.code} — {err_body[:500]}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
if attempt < max_attempts:
|
||||||
|
wait = backoff * attempt
|
||||||
|
print(
|
||||||
|
f"⚠️ Cannot reach Signal REST API ({e.reason}); retrying in "
|
||||||
|
f"{wait:.0f}s (attempt {attempt}/{max_attempts})...",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
time.sleep(wait)
|
||||||
|
continue
|
||||||
|
print(f"❌ Cannot reach Signal REST API at {SIGNAL_REST_URL}: {e.reason}", file=sys.stderr)
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def signal_rest_get(endpoint):
|
def signal_rest_get(endpoint):
|
||||||
@ -306,69 +448,40 @@ def cmd_signal(args):
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
attach_files = getattr(args, "attach", None) or getattr(args, "file", None)
|
attach_files = getattr(args, "attach", None) or getattr(args, "file", None)
|
||||||
|
voice_file = getattr(args, "voice", None)
|
||||||
|
|
||||||
# Text or an attachment is required in ALL cases, including group sends.
|
# Text, an attachment, or a voice note is required in ALL cases.
|
||||||
if not args.text and not attach_files:
|
if not args.text and not attach_files and not voice_file:
|
||||||
print("❌ Error: --text <message> or --attach <file> is required.", file=sys.stderr)
|
print("❌ Error: --text <message>, --attach <file>, or --voice <audio> is required.", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
account = normalize_account(args.account or SIGNAL_DEFAULT_ACCOUNT)
|
account = normalize_account(args.account or SIGNAL_DEFAULT_ACCOUNT)
|
||||||
|
|
||||||
# Build the JSON payload for /v2/send
|
|
||||||
payload = {"number": account}
|
|
||||||
if args.recipients:
|
if args.recipients:
|
||||||
# Send to a Signal group — use recipients array with the group ID.
|
recipient = None
|
||||||
payload["recipients"] = [args.recipients]
|
group_id = args.recipients
|
||||||
else:
|
else:
|
||||||
# Send to an individual recipient. The REST API accepts a
|
recipient = args.to[0]
|
||||||
# recipients array for direct messages too; using it consistently
|
group_id = None
|
||||||
# avoids the 1:1-vs-group dispatch mismatch behind issue #3.
|
|
||||||
payload["recipients"] = [args.to[0]]
|
|
||||||
|
|
||||||
# Message text (join multiple --text args)
|
message = "\n".join(args.text) if args.text else None
|
||||||
if args.text:
|
|
||||||
payload["message"] = "\n".join(args.text)
|
|
||||||
|
|
||||||
# Attachments
|
ok = send_one_signal(
|
||||||
if attach_files and len(attach_files) > 0:
|
account=account,
|
||||||
if len(attach_files) == 1:
|
recipient=recipient,
|
||||||
# Single file — send as multipart with the message.
|
message=message,
|
||||||
result = signal_rest_post("/v2/send", payload=payload, files=attach_files)
|
group_id=group_id,
|
||||||
else:
|
attach=attach_files,
|
||||||
# Multiple files: send the text first, then each attachment.
|
voice=voice_file,
|
||||||
multi_payload = {"number": account, "message": payload.get("message", "")}
|
)
|
||||||
multi_payload["recipients"] = payload["recipients"]
|
|
||||||
|
|
||||||
# Only send a standalone text message if there is actual text.
|
if ok:
|
||||||
result = None
|
|
||||||
if multi_payload["message"]:
|
|
||||||
result = signal_rest_post("/v2/send", payload=multi_payload)
|
|
||||||
if result is None:
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# Then send each attachment.
|
|
||||||
for fpath in attach_files:
|
|
||||||
att_payload = {"number": account, "recipients": payload["recipients"]}
|
|
||||||
att_result = signal_rest_post("/v2/send", payload=att_payload, files=[fpath])
|
|
||||||
if att_result is None:
|
|
||||||
print(f"⚠️ Attachment failed: {fpath}", file=sys.stderr)
|
|
||||||
else:
|
|
||||||
result = att_result
|
|
||||||
else:
|
|
||||||
# Text-only send (direct or group).
|
|
||||||
result = signal_rest_post("/v2/send", payload=payload)
|
|
||||||
|
|
||||||
if result is not None:
|
|
||||||
if args.recipients:
|
if args.recipients:
|
||||||
print(f"✅ Signal message sent to group {args.recipients}")
|
print(f"✅ Signal message sent to group {args.recipients}")
|
||||||
elif args.to:
|
elif args.to:
|
||||||
print(f"✅ Signal message sent to {args.to[0]}")
|
print(f"✅ Signal message sent to {normalize_recipient(args.to[0])}")
|
||||||
else:
|
else:
|
||||||
print("✅ Signal message sent")
|
print("✅ Signal message sent")
|
||||||
if args.verbose and isinstance(result, dict):
|
|
||||||
for key, value in result.items():
|
|
||||||
if key not in ("success",):
|
|
||||||
print(f" {key}: {value}")
|
|
||||||
else:
|
else:
|
||||||
print("❌ Signal message send failed.", file=sys.stderr)
|
print("❌ Signal message send failed.", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
@ -382,30 +495,33 @@ def cmd_sms(args):
|
|||||||
if not args.text:
|
if not args.text:
|
||||||
print("❌ Error: --text <message> is required for SMS.", file=sys.stderr)
|
print("❌ Error: --text <message> is required for SMS.", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
if not args.service:
|
||||||
|
print("❌ Error: --service <sms|imessage> is required for SMS.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
cmd = ["imsg", "send", "--to", args.to[0]]
|
|
||||||
if args.service:
|
|
||||||
cmd.extend(["--service", args.service])
|
|
||||||
msg_text = "\n".join(args.text)
|
msg_text = "\n".join(args.text)
|
||||||
cmd.extend(["--text", msg_text])
|
|
||||||
|
|
||||||
attach_files = getattr(args, "file", None) or getattr(args, "attach", None)
|
attach_files = getattr(args, "file", None) or getattr(args, "attach", None)
|
||||||
|
|
||||||
|
# imsg accepts a single --file; send the first valid one and warn on extras.
|
||||||
|
chosen_file = None
|
||||||
if attach_files:
|
if attach_files:
|
||||||
for f in attach_files:
|
for f in attach_files:
|
||||||
f = expand_path(f)
|
ef = expand_path(f)
|
||||||
if not os.path.isfile(f):
|
if not os.path.isfile(ef):
|
||||||
print(f"⚠️ Warning: file not found, skipping: {f}", file=sys.stderr)
|
print(f"⚠️ Warning: file not found, skipping: {ef}", file=sys.stderr)
|
||||||
continue
|
continue
|
||||||
cmd.extend(["--file", f])
|
if chosen_file is None:
|
||||||
|
chosen_file = ef
|
||||||
|
else:
|
||||||
|
print(f"⚠️ Warning: SMS supports one attachment; ignoring extra file: {ef}", file=sys.stderr)
|
||||||
|
|
||||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
ok = send_one_sms(
|
||||||
if result.returncode == 0:
|
to=args.to[0],
|
||||||
service_label = args.service.upper() if args.service else "AUTO"
|
message=msg_text,
|
||||||
print(f"✅ SMS/iMessage sent to {args.to[0]} (service: {service_label})")
|
service=args.service,
|
||||||
else:
|
file=chosen_file,
|
||||||
print(f"❌ SMS send failed (exit {result.returncode})", file=sys.stderr)
|
)
|
||||||
if result.stderr:
|
if not ok:
|
||||||
print(f" {result.stderr.strip()}", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
@ -413,7 +529,7 @@ def cmd_csv(args):
|
|||||||
"""Bulk send messages from a CSV file.
|
"""Bulk send messages from a CSV file.
|
||||||
|
|
||||||
Expected CSV columns:
|
Expected CSV columns:
|
||||||
method, recipient, name, message, account, service, file, delay
|
method, recipient, name, message, account, service, file, voice, delay
|
||||||
"""
|
"""
|
||||||
filepath = args.csv
|
filepath = args.csv
|
||||||
if not os.path.isfile(filepath):
|
if not os.path.isfile(filepath):
|
||||||
@ -432,9 +548,33 @@ def cmd_csv(args):
|
|||||||
print("❌ Error: CSV file is empty (no data rows).", file=sys.stderr)
|
print("❌ Error: CSV file is empty (no data rows).", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Count the rows that will actually be attempted (non-blank).
|
||||||
|
actionable = [
|
||||||
|
r for r in rows
|
||||||
|
if (r.get("method", "").strip()
|
||||||
|
or r.get("message", "").strip()
|
||||||
|
or r.get("recipient", "").strip()
|
||||||
|
or r.get("file", "").strip()
|
||||||
|
or r.get("voice", "").strip())
|
||||||
|
]
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
print(f"🔎 Dry run: {len(actionable)} row(s) would be processed from {filepath}\n")
|
||||||
|
elif len(actionable) > CSV_CONFIRM_THRESHOLD and not args.yes:
|
||||||
|
try:
|
||||||
|
resp = input(
|
||||||
|
f"⚠️ About to send {len(actionable)} messages. Continue? [y/N] "
|
||||||
|
).strip().lower()
|
||||||
|
except EOFError:
|
||||||
|
resp = "n"
|
||||||
|
if resp not in ("y", "yes"):
|
||||||
|
print("Aborted.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
total = len(rows)
|
total = len(rows)
|
||||||
success = 0
|
success = 0
|
||||||
failed = 0
|
failed = 0
|
||||||
|
skipped = 0
|
||||||
errors = []
|
errors = []
|
||||||
|
|
||||||
for i, row in enumerate(rows, 1):
|
for i, row in enumerate(rows, 1):
|
||||||
@ -442,9 +582,10 @@ def cmd_csv(args):
|
|||||||
message = row.get("message", "").strip()
|
message = row.get("message", "").strip()
|
||||||
to = row.get("recipient", "").strip()
|
to = row.get("recipient", "").strip()
|
||||||
name = row.get("name", "").strip()
|
name = row.get("name", "").strip()
|
||||||
|
voice_path = row.get("voice", "").strip() or None
|
||||||
|
|
||||||
# Skip blank rows
|
# Skip blank rows
|
||||||
if not method and not message and not to:
|
if not method and not message and not to and not voice_path:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if not method:
|
if not method:
|
||||||
@ -456,15 +597,9 @@ def cmd_csv(args):
|
|||||||
errors.append(f"Row {i}: unknown method '{method}'")
|
errors.append(f"Row {i}: unknown method '{method}'")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if not message:
|
|
||||||
print(f"⚠️ Row {i}: empty message — skipping", file=sys.stderr)
|
|
||||||
failed += 1
|
|
||||||
errors.append(f"Row {i}: empty message")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Common fields
|
# Common fields
|
||||||
account = row.get("account", "").strip() or SIGNAL_DEFAULT_ACCOUNT
|
account = row.get("account", "").strip() or SIGNAL_DEFAULT_ACCOUNT
|
||||||
service = row.get("service", "").strip() or None
|
service = row.get("service", "").strip().lower() or None
|
||||||
file_path = row.get("file", "").strip() or None
|
file_path = row.get("file", "").strip() or None
|
||||||
delay_str = row.get("delay", "").strip()
|
delay_str = row.get("delay", "").strip()
|
||||||
try:
|
try:
|
||||||
@ -473,8 +608,55 @@ def cmd_csv(args):
|
|||||||
print(f"⚠️ Row {i}: invalid delay '{delay_str}' — ignoring", file=sys.stderr)
|
print(f"⚠️ Row {i}: invalid delay '{delay_str}' — ignoring", file=sys.stderr)
|
||||||
row_delay = None
|
row_delay = None
|
||||||
|
|
||||||
|
# A message is required UNLESS there is an attachment or a voice note
|
||||||
|
# (Signal can send attachment-only / voice-only messages). SMS still
|
||||||
|
# requires text.
|
||||||
|
has_signal_media = method == "signal" and (file_path or voice_path)
|
||||||
|
if not message and not has_signal_media:
|
||||||
|
print(f"⚠️ Row {i}: empty message — skipping", file=sys.stderr)
|
||||||
|
skipped += 1
|
||||||
|
errors.append(f"Row {i}: empty message")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if method == "sms":
|
||||||
|
if voice_path:
|
||||||
|
print(
|
||||||
|
f"⚠️ Row {i}: voice notes are only supported for Signal "
|
||||||
|
f"(method 'signal') — skipping",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
failed += 1
|
||||||
|
errors.append(f"Row {i}: voice not supported for SMS")
|
||||||
|
continue
|
||||||
|
if service not in ("sms", "imessage"):
|
||||||
|
print(
|
||||||
|
f"⚠️ Row {i}: SMS requires service 'sms' or 'imessage' "
|
||||||
|
f"(got '{service or ''}') — skipping",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
failed += 1
|
||||||
|
errors.append(f"Row {i}: invalid/missing SMS service")
|
||||||
|
continue
|
||||||
|
|
||||||
label = f"{name} ({to})" if name else to or "(group)"
|
label = f"{name} ({to})" if name else to or "(group)"
|
||||||
print(f"[{i}/{total}] {method.upper()} → {label}")
|
prefix = "[DRY] " if args.dry_run else ""
|
||||||
|
print(f"{prefix}[{i}/{total}] {method.upper()} → {label}")
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
# Report what would happen without sending.
|
||||||
|
extras = []
|
||||||
|
if file_path:
|
||||||
|
extras.append(f"file={file_path}")
|
||||||
|
if voice_path:
|
||||||
|
extras.append(f"voice={voice_path}")
|
||||||
|
if method == "signal" and is_group_id(to):
|
||||||
|
extras.append("group")
|
||||||
|
if method == "sms":
|
||||||
|
extras.append(f"service={service}")
|
||||||
|
if extras:
|
||||||
|
print(f" ({', '.join(extras)})")
|
||||||
|
success += 1
|
||||||
|
continue
|
||||||
|
|
||||||
if method == "signal":
|
if method == "signal":
|
||||||
# Detect groups robustly (issue #3): raw base64 group IDs do NOT
|
# Detect groups robustly (issue #3): raw base64 group IDs do NOT
|
||||||
@ -487,10 +669,11 @@ def cmd_csv(args):
|
|||||||
message=message,
|
message=message,
|
||||||
group_id=group_id,
|
group_id=group_id,
|
||||||
attach=file_path,
|
attach=file_path,
|
||||||
|
voice=voice_path,
|
||||||
)
|
)
|
||||||
else: # sms
|
else: # sms
|
||||||
result = send_one_sms(
|
result = send_one_sms(
|
||||||
to=to,
|
to=normalize_account(to),
|
||||||
message=message,
|
message=message,
|
||||||
service=service,
|
service=service,
|
||||||
file=file_path,
|
file=file_path,
|
||||||
@ -504,20 +687,21 @@ def cmd_csv(args):
|
|||||||
|
|
||||||
# Per-row delay overrides global; otherwise use the global --delay.
|
# Per-row delay overrides global; otherwise use the global --delay.
|
||||||
effective_delay = row_delay if row_delay is not None else global_delay
|
effective_delay = row_delay if row_delay is not None else global_delay
|
||||||
if effective_delay > 0:
|
if effective_delay > 0 and not args.dry_run:
|
||||||
time.sleep(effective_delay)
|
time.sleep(effective_delay)
|
||||||
|
|
||||||
# Summary
|
# Summary
|
||||||
print()
|
print()
|
||||||
print("=" * 50)
|
print("=" * 50)
|
||||||
print("📊 CSV Send Summary")
|
print("📊 CSV Send Summary" + (" (DRY RUN)" if args.dry_run else ""))
|
||||||
print("=" * 50)
|
print("=" * 50)
|
||||||
print(f" Total rows: {total}")
|
print(f" Total rows: {total}")
|
||||||
print(f" ✅ Success: {success}")
|
print(f" ✅ Success: {success}")
|
||||||
|
print(f" ⏭️ Skipped: {skipped}")
|
||||||
print(f" ❌ Failed: {failed}")
|
print(f" ❌ Failed: {failed}")
|
||||||
if errors:
|
if errors:
|
||||||
print()
|
print()
|
||||||
print(" Errors:")
|
print(" Notes:")
|
||||||
for e in errors:
|
for e in errors:
|
||||||
print(f" • {e}")
|
print(f" • {e}")
|
||||||
print("=" * 50)
|
print("=" * 50)
|
||||||
@ -597,6 +781,14 @@ def cmd_link_signal(args):
|
|||||||
This prints the sgnl:// URL to stdout, then waits for the user to scan/confirm.
|
This prints the sgnl:// URL to stdout, then waits for the user to scan/confirm.
|
||||||
"""
|
"""
|
||||||
name = args.name or "automation"
|
name = args.name or "automation"
|
||||||
|
|
||||||
|
# Snapshot existing accounts so we can detect a genuinely NEW link rather
|
||||||
|
# than reporting success just because a primary account already exists.
|
||||||
|
before = signal_rest_get("/v1/accounts")
|
||||||
|
before_set = set()
|
||||||
|
if isinstance(before, list):
|
||||||
|
before_set = {str(a) for a in before}
|
||||||
|
|
||||||
payload = {"name": name}
|
payload = {"name": name}
|
||||||
result = signal_rest_post("/v1/link", payload=payload)
|
result = signal_rest_post("/v1/link", payload=payload)
|
||||||
if result is None:
|
if result is None:
|
||||||
@ -619,12 +811,15 @@ def cmd_link_signal(args):
|
|||||||
start = time.time()
|
start = time.time()
|
||||||
while time.time() - start < 120:
|
while time.time() - start < 120:
|
||||||
time.sleep(3)
|
time.sleep(3)
|
||||||
result = signal_rest_get("/v1/accounts")
|
current = signal_rest_get("/v1/accounts")
|
||||||
if result and (isinstance(result, list) and len(result) > 0):
|
if isinstance(current, list):
|
||||||
print(f"✅ Device linked! Account: {result}")
|
current_set = {str(a) for a in current}
|
||||||
return
|
new_accounts = current_set - before_set
|
||||||
|
if new_accounts:
|
||||||
|
print(f"✅ Device linked! New account(s): {', '.join(sorted(new_accounts))}")
|
||||||
|
return
|
||||||
|
|
||||||
print("⚠️ Linking timed out. Please try again.", file=sys.stderr)
|
print("⚠️ Linking timed out (no new account detected). Please try again.", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@ -639,16 +834,18 @@ def main():
|
|||||||
"Examples:\n"
|
"Examples:\n"
|
||||||
" %(prog)s --signal --to +18005551212 --text 'Hello!'\n"
|
" %(prog)s --signal --to +18005551212 --text 'Hello!'\n"
|
||||||
" %(prog)s --signal --recipients GROUP_ID --text 'Hello group!'\n"
|
" %(prog)s --signal --recipients GROUP_ID --text 'Hello group!'\n"
|
||||||
" %(prog)s --sms --to +18005551212 --text 'Text via SMS'\n"
|
" %(prog)s --signal --to +18005551212 --voice ~/note.m4a\n"
|
||||||
" %(prog)s --sms --to +18005551212 --text 'Hey' --file ~/pic.jpg\n"
|
" %(prog)s --sms --to +18005551212 --text 'Text' --service sms\n"
|
||||||
|
" %(prog)s --sms --to +18005551212 --text 'Hey' --service imessage --file ~/pic.jpg\n"
|
||||||
" %(prog)s --csv messages.csv\n"
|
" %(prog)s --csv messages.csv\n"
|
||||||
" %(prog)s --csv messages.csv --delay 2\n"
|
" %(prog)s --csv messages.csv --delay 2\n"
|
||||||
|
" %(prog)s --csv messages.csv --dry-run\n"
|
||||||
" %(prog)s --list-signal\n"
|
" %(prog)s --list-signal\n"
|
||||||
" %(prog)s --link-signal\n"
|
|
||||||
" %(prog)s --link-signal --name 'my-automation'\n"
|
" %(prog)s --link-signal --name 'my-automation'\n"
|
||||||
"\n"
|
"\n"
|
||||||
"CSV columns: method, recipient, name, message, account, service, file, delay\n"
|
"CSV columns: method, recipient, name, message, account, service, file, delay\n"
|
||||||
"The 'name' field is printed in the status output during CSV sends.\n"
|
"The 'name' field is printed in the status output during CSV sends.\n"
|
||||||
|
"For SMS rows the 'service' column must be 'sms' or 'imessage'.\n"
|
||||||
f"Signal REST API: {SIGNAL_REST_URL}\n"
|
f"Signal REST API: {SIGNAL_REST_URL}\n"
|
||||||
"Config file: ~/.sendmsg.conf\n"
|
"Config file: ~/.sendmsg.conf\n"
|
||||||
" [settings]\n"
|
" [settings]\n"
|
||||||
@ -677,6 +874,7 @@ def main():
|
|||||||
signal_grp = parser.add_argument_group("Signal options")
|
signal_grp = parser.add_argument_group("Signal options")
|
||||||
signal_grp.add_argument("--account", metavar="PHONE", help="Signal account phone number to use")
|
signal_grp.add_argument("--account", metavar="PHONE", help="Signal account phone number to use")
|
||||||
signal_grp.add_argument("--recipients", metavar="RECIPIENTS", help="Send to Signal group ID (no --to needed)")
|
signal_grp.add_argument("--recipients", metavar="RECIPIENTS", help="Send to Signal group ID (no --to needed)")
|
||||||
|
signal_grp.add_argument("--voice", metavar="AUDIO", help="Send an audio file as a Signal voice message")
|
||||||
signal_grp.add_argument("--name", metavar="NAME", help="Device name for Signal linking (default: 'automation')")
|
signal_grp.add_argument("--name", metavar="NAME", help="Device name for Signal linking (default: 'automation')")
|
||||||
|
|
||||||
# Management commands
|
# Management commands
|
||||||
@ -688,27 +886,46 @@ def main():
|
|||||||
|
|
||||||
# SMS-specific
|
# SMS-specific
|
||||||
sms_grp = parser.add_argument_group("SMS/iMessage options")
|
sms_grp = parser.add_argument_group("SMS/iMessage options")
|
||||||
sms_grp.add_argument("--service", choices=["imessage", "sms", "auto"], help="Force service (default: auto-detect)")
|
sms_grp.add_argument("--service", choices=["imessage", "sms"],
|
||||||
|
help="Service to use: 'imessage' or 'sms' (required for --sms)")
|
||||||
|
|
||||||
# CSV-specific
|
# CSV-specific
|
||||||
csv_grp = parser.add_argument_group("CSV options")
|
csv_grp = parser.add_argument_group("CSV options")
|
||||||
csv_grp.add_argument("--delay", metavar="SECONDS", help="Wait N seconds between CSV row sends (default: 0)")
|
csv_grp.add_argument("--delay", metavar="SECONDS", help="Wait N seconds between CSV row sends (default: 0)")
|
||||||
|
csv_grp.add_argument("--dry-run", action="store_true", help="Preview a CSV batch without sending")
|
||||||
|
csv_grp.add_argument("--yes", "-y", action="store_true",
|
||||||
|
help="Skip the large-batch confirmation prompt")
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
# cmd_signal still reads args.attach in places; keep an alias.
|
# cmd_signal still reads args.attach in places; keep an alias.
|
||||||
args.attach = args.file
|
args.attach = args.file
|
||||||
|
|
||||||
# Validate: must have a method or management command
|
# Validate: must have exactly one method or management command.
|
||||||
if not any([args.signal, args.sms, args.csv, args.list_signal, args.link_signal, args.show_config]):
|
methods = {
|
||||||
|
"--signal": args.signal,
|
||||||
|
"--sms": args.sms,
|
||||||
|
"--csv": bool(args.csv),
|
||||||
|
"--list-signal": args.list_signal,
|
||||||
|
"--link-signal": args.link_signal,
|
||||||
|
"--show-config": args.show_config,
|
||||||
|
}
|
||||||
|
chosen = [name for name, on in methods.items() if on]
|
||||||
|
|
||||||
|
if not chosen:
|
||||||
parser.print_help()
|
parser.print_help()
|
||||||
print("\n❌ Error: Specify --signal, --sms, --csv, --list-signal, --link-signal, or --show-config", file=sys.stderr)
|
print("\n❌ Error: Specify --signal, --sms, --csv, --list-signal, --link-signal, or --show-config", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
if sum([bool(args.signal), bool(args.sms), bool(args.csv)]) > 1:
|
if len(chosen) > 1:
|
||||||
print("❌ Error: Choose only one method (--signal, --sms, or --csv).", file=sys.stderr)
|
print(f"❌ Error: Choose only one action (got: {', '.join(chosen)}).", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
# --voice is single-file only.
|
||||||
|
if args.voice and args.file and len(args.file) > 0:
|
||||||
|
# Allow voice + attachments, but warn it's two separate sends.
|
||||||
|
print("ℹ️ Note: --voice and --attach will be sent as separate messages.", file=sys.stderr)
|
||||||
|
|
||||||
# Set defaults
|
# Set defaults
|
||||||
if args.link_signal and not args.name:
|
if args.link_signal and not args.name:
|
||||||
args.name = "automation"
|
args.name = "automation"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user