mirror of
https://github.com/docwho76/sendmsg.git
synced 2026-08-31 01:41:10 -07:00
Update docs, fix bugs with group sending and adding attachments
This commit is contained in:
parent
7d99502be4
commit
5a746b5470
247
README.md
247
README.md
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Universal message sender for Signal and SMS/iMessage.**
|
**Universal message sender for Signal and SMS/iMessage.**
|
||||||
|
|
||||||
`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`](https://github.com/DocWho76/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, and bulk sending from CSV files — all with a single, consistent interface.
|
||||||
|
|
||||||
@ -14,13 +14,15 @@ It supports individual direct messages, Signal group broadcasts, file attachment
|
|||||||
- [Dependencies](#dependencies)
|
- [Dependencies](#dependencies)
|
||||||
- [Installation](#installation)
|
- [Installation](#installation)
|
||||||
- [Usage](#usage)
|
- [Usage](#usage)
|
||||||
- [Signal Messages](#signal-messages)
|
* [Signal Messages](#signal-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)
|
||||||
- [CSV Format](#csv-format)
|
- [CSV Format](#csv-format)
|
||||||
- [Configuration](#configuration)
|
- [Configuration](#configuration)
|
||||||
- [Examples](#examples)
|
- [Examples](#examples)
|
||||||
|
- [Error Handling](#error-handling)
|
||||||
|
- [Summary Output](#summary-output)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -35,9 +37,9 @@ It supports individual direct messages, Signal group broadcasts, file attachment
|
|||||||
│ ┌──────────────┐ ┌──────────────────────────┐ │
|
│ ┌──────────────┐ ┌──────────────────────────┐ │
|
||||||
│ │ --signal │ │ --sms │ │
|
│ │ --signal │ │ --sms │ │
|
||||||
│ │ │ │ │ │
|
│ │ │ │ │ │
|
||||||
│ │ POST to │ │ Call `imsg send` CLI │ │
|
│ │ POST JSON │ │ Call `imsg send` CLI │ │
|
||||||
│ │ Signal REST │ │ │ │
|
│ │ to Signal │ │ │ │
|
||||||
│ │ API │ │ │ │
|
│ │ REST API │ │ │ │
|
||||||
│ └──────┬───────┘ └────────────┬─────────────┘ │
|
│ └──────┬───────┘ └────────────┬─────────────┘ │
|
||||||
│ │ │ │
|
│ │ │ │
|
||||||
│ ▼ │ │
|
│ ▼ │ │
|
||||||
@ -51,7 +53,7 @@ It supports individual direct messages, Signal group broadcasts, file attachment
|
|||||||
└─────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
**Signal path:** `sendmsg` → HTTP POST → `signal-cli-rest-api` → Docker container → Signal network.
|
**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).
|
||||||
|
|
||||||
**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.
|
||||||
|
|
||||||
@ -61,24 +63,26 @@ 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 |
|
| macOS 12+ | Monterey or later | Required for iMessage/SMS support |
|
||||||
|
|
||||||
|
> `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+ (Colima on Apple Silicon) | 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 |
|
||||||
|
|
||||||
### 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 |
|
| `imsg` CLI | latest | macOS Messages / SMS bridge |
|
||||||
|
|
||||||
### System
|
### System
|
||||||
|
|
||||||
@ -91,30 +95,33 @@ It supports individual direct messages, Signal group broadcasts, file attachment
|
|||||||
|
|
||||||
1. **Place the script:**
|
1. **Place the script:**
|
||||||
|
|
||||||
```bash
|
```
|
||||||
cp sendmsg /usr/local/bin/sendmsg
|
cp sendmsg /usr/local/bin/sendmsg
|
||||||
chmod +x /usr/local/bin/sendmsg
|
chmod +x /usr/local/bin/sendmsg
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Ensure dependencies are installed:**
|
2. **Ensure dependencies are installed:**
|
||||||
|
|
||||||
**For Signal:**
|
**For Signal:**
|
||||||
```bash
|
|
||||||
# Verify signal-cli-rest-api is running
|
```
|
||||||
curl -s http://localhost:8080/v1/accounts | python3 -m json.tool
|
# Verify signal-cli-rest-api is running
|
||||||
```
|
curl -s http://localhost:8080/v1/accounts | python3 -m json.tool
|
||||||
|
```
|
||||||
|
|
||||||
**For SMS/iMessage:**
|
**For SMS/iMessage:**
|
||||||
```bash
|
|
||||||
# Verify imsg is installed and available
|
```
|
||||||
imsg send --help
|
# Verify imsg is installed and available
|
||||||
```
|
imsg send --help
|
||||||
|
```
|
||||||
|
|
||||||
3. **Test the script:**
|
3. **Test the script:**
|
||||||
|
|
||||||
```bash
|
```
|
||||||
sendmsg --help
|
sendmsg --help
|
||||||
```
|
sendmsg --show-config # confirm where settings are being read from
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -131,11 +138,12 @@ Messaging methods (choose one):
|
|||||||
Management commands (choose one):
|
Management commands (choose one):
|
||||||
--list-signal List linked Signal accounts
|
--list-signal List linked Signal accounts
|
||||||
--link-signal Link a new device to your Signal account
|
--link-signal Link a new device to your Signal account
|
||||||
|
--show-config Print resolved configuration and where each value came from
|
||||||
```
|
```
|
||||||
|
|
||||||
### Signal Messages
|
### Signal Messages
|
||||||
|
|
||||||
```bash
|
```
|
||||||
# Send a text message
|
# Send a text message
|
||||||
sendmsg --signal --to +18885551212 --text "Hello from Signal!"
|
sendmsg --signal --to +18885551212 --text "Hello from Signal!"
|
||||||
|
|
||||||
@ -147,11 +155,18 @@ sendmsg --signal --to +18885551212 --text "Check this out" --attach ~/photo.jpg
|
|||||||
|
|
||||||
# Send multiple messages (concatenated with newlines)
|
# Send multiple messages (concatenated with newlines)
|
||||||
sendmsg --signal --to +18885551212 --text "Line 1" --text "Line 2"
|
sendmsg --signal --to +18885551212 --text "Line 1" --text "Line 2"
|
||||||
|
|
||||||
|
# Send multiple attachments
|
||||||
|
sendmsg --signal --to +18885551212 --text "Files" --attach ~/doc.pdf ~/pic.jpg
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> 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.
|
||||||
|
|
||||||
### SMS / iMessage
|
### SMS / iMessage
|
||||||
|
|
||||||
```bash
|
```
|
||||||
# Send via iMessage (auto-detected)
|
# Send via iMessage (auto-detected)
|
||||||
sendmsg --sms --to +18885551212 --text "Hello via iMessage!"
|
sendmsg --sms --to +18885551212 --text "Hello via iMessage!"
|
||||||
|
|
||||||
@ -164,20 +179,24 @@ sendmsg --sms --to +18885551212 --text "Photo attached" --file ~/photo.jpg
|
|||||||
|
|
||||||
### Bulk Send from CSV
|
### Bulk Send from CSV
|
||||||
|
|
||||||
```bash
|
```
|
||||||
sendmsg --csv messages.csv # Send all rows
|
sendmsg --csv messages.csv # Send all rows
|
||||||
sendmsg --csv messages.csv --delay 2 # Wait 2 seconds between sends
|
sendmsg --csv messages.csv --delay 2 # Wait 2 seconds between every send
|
||||||
```
|
```
|
||||||
|
|
||||||
### Management Commands
|
### Management Commands
|
||||||
|
|
||||||
```bash
|
```
|
||||||
# List linked Signal accounts
|
# List linked Signal accounts
|
||||||
sendmsg --list-signal
|
sendmsg --list-signal
|
||||||
|
|
||||||
# Link a new device
|
# Link a new device
|
||||||
sendmsg --link-signal
|
sendmsg --link-signal
|
||||||
sendmsg --link-signal --name "my-laptop"
|
sendmsg --link-signal --name "my-laptop"
|
||||||
|
|
||||||
|
# Show the active configuration and its sources
|
||||||
|
sendmsg --show-config
|
||||||
|
sendmsg --show-config -v # also checks whether the Signal REST API is reachable
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -186,25 +205,31 @@ sendmsg --link-signal --name "my-laptop"
|
|||||||
|
|
||||||
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` |
|
| `method` | Yes | `signal` or `sms` (defaults to `signal` if left blank) |
|
||||||
| `recipient` | Yes | Phone number, group ID (for Signal), or empty for group sends |
|
| `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` | Yes | The message text to send |
|
||||||
| `account` | No | Signal account phone number (defaults to `+156****1603` or `$SIGNAL_ACCOUNT`) |
|
| `account` | No | Signal account phone number (defaults to `$SIGNAL_ACCOUNT` / config value) |
|
||||||
| `service` | No | SMS service: `imessage`, `sms`, or `auto` (SMS only) |
|
| `service` | No | SMS service: `imessage`, `sms`, or `auto` (SMS only) |
|
||||||
| `file` | No | Path to an attachment file (relative or absolute) |
|
| `file` | No | Path to an attachment file. `~` and environment variables are expanded. |
|
||||||
| `delay` | No | Seconds to wait after this row is sent (e.g., `3`) |
|
| `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.
|
||||||
|
- 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
|
||||||
|
|
||||||
```csv
|
```
|
||||||
method,recipient,name,message,account,service,file,delay
|
method,recipient,name,message,account,service,file,delay
|
||||||
signal,+156****1603,Alice,Hello via Signal,+156****1603,,,
|
signal,+18885551111,Alice,Hello via Signal,+18885551111,,,
|
||||||
signal,group.ZzBHd3NZO...,Team Alert,,,,-1
|
signal,group.ZzBHd3NZO...,Team Alert,Morning update for the team,,,,
|
||||||
sms,+123****7890,Bob,SMS test,,,~/pic.jpg,
|
sms,+18885552222,Bob,SMS test,,,~/pic.jpg,
|
||||||
signal,+156****1603,Carol,With delay,,,-3
|
signal,+18885553333,Carol,With a delay after this row,,,,3
|
||||||
```
|
```
|
||||||
|
|
||||||
### Status Output
|
### Status Output
|
||||||
@ -212,10 +237,10 @@ signal,+156****1603,Carol,With delay,,,-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 (+156****1603)
|
[1/4] SIGNAL → Alice (+18885551111)
|
||||||
[2/4] SIGNAL → Team Alert (group.ZzBHd3NZO...)
|
[2/4] SIGNAL → Team Alert (group.ZzBHd3NZO...)
|
||||||
[3/4] SMS → Bob (+123****7890)
|
[3/4] SMS → Bob (+18885552222)
|
||||||
[4/4] SIGNAL → Carol (+156****1603)
|
[4/4] SIGNAL → Carol (+18885553333)
|
||||||
```
|
```
|
||||||
|
|
||||||
If no `name` is provided, the recipient is shown instead.
|
If no `name` is provided, the recipient is shown instead.
|
||||||
@ -224,23 +249,55 @@ If no `name` is provided, the recipient is shown instead.
|
|||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
### Load settings from sendmsg config file in ~/.sendmsg.conf, with env var fallback.
|
Settings are read from a config file at `~/.sendmsg.conf`, with environment
|
||||||
|
variable and built-in fallbacks.
|
||||||
|
|
||||||
Config file format (INI-style):
|
**Resolution order:** environment variable → config file → built-in default.
|
||||||
[settings]
|
|
||||||
signal_rest_url = http://localhost:8080
|
### Config file format (INI-style)
|
||||||
signal_default_account = +1234567890
|
|
||||||
|
```
|
||||||
|
[settings]
|
||||||
|
signal_rest_url = http://localhost:8080
|
||||||
|
signal_default_account = +1234567890
|
||||||
|
```
|
||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|---|---|---|
|
| ----------------- | ----------------------- | ----------------------------------- |
|
||||||
| `SIGNAL_REST_URL` | `http://localhost:8080` | URL of the Signal REST API server |
|
| `SIGNAL_REST_URL` | `http://localhost:8080` | URL of the Signal REST API server |
|
||||||
| `SIGNAL_ACCOUNT` | `+1234567890` | Default Signal account phone number |
|
| `SIGNAL_ACCOUNT` | `+1234567890` | Default Signal account phone number |
|
||||||
|
|
||||||
|
### Inspecting the active configuration
|
||||||
|
|
||||||
|
Use `--show-config` to print the resolved values and exactly where each one
|
||||||
|
came from (environment, config file, or built-in default):
|
||||||
|
|
||||||
|
```
|
||||||
|
$ sendmsg --show-config
|
||||||
|
==================================================
|
||||||
|
⚙️ sendmsg configuration
|
||||||
|
==================================================
|
||||||
|
Version: 4.3.0
|
||||||
|
|
||||||
|
Config file: /Users/you/.sendmsg.conf (found)
|
||||||
|
|
||||||
|
signal_rest_url: http://localhost:8080
|
||||||
|
└─ source: config (/Users/you/.sendmsg.conf)
|
||||||
|
|
||||||
|
signal_default_account: +18885551212
|
||||||
|
└─ source: env ($SIGNAL_ACCOUNT)
|
||||||
|
|
||||||
|
Resolution order: env var > config file > built-in default
|
||||||
|
==================================================
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `-v` to also probe whether the Signal REST API is currently reachable.
|
||||||
|
|
||||||
### Override Example
|
### Override Example
|
||||||
|
|
||||||
```bash
|
```
|
||||||
export SIGNAL_REST_URL=http://localhost:8082
|
export SIGNAL_REST_URL=http://localhost:8082
|
||||||
export SIGNAL_ACCOUNT=+18885551212
|
export SIGNAL_ACCOUNT=+18885551212
|
||||||
sendmsg --csv messages.csv
|
sendmsg --csv messages.csv
|
||||||
@ -252,36 +309,42 @@ sendmsg --csv messages.csv
|
|||||||
|
|
||||||
### Daily Broadcast
|
### Daily Broadcast
|
||||||
|
|
||||||
```csv
|
```
|
||||||
method,recipient,name,message,account,service,file,delay
|
method,recipient,name,message,account,service,file,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
|
||||||
|
|
||||||
```csv
|
|
||||||
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 about the meeting tomorrow at 3pm!
|
|
||||||
```
|
```
|
||||||
|
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,,,,
|
||||||
|
```
|
||||||
|
|
||||||
|
> Avoid commas inside the `message` field unless the field is quoted, since
|
||||||
|
> commas are the CSV column separator.
|
||||||
|
|
||||||
### With Attachments
|
### With Attachments
|
||||||
|
|
||||||
```csv
|
```
|
||||||
method,recipient,name,message,account,service,file,delay
|
method,recipient,name,message,account,service,file,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,,,~/Photos/event.jpg,
|
||||||
```
|
```
|
||||||
|
|
||||||
### With Delays
|
### With Delays
|
||||||
|
|
||||||
```csv
|
|
||||||
method,recipient,name,message,account,service,file,delay
|
|
||||||
signal,+18885551111,Alice,First message,,,
|
|
||||||
signal,+18885552222,Bob,Second message,,,3
|
|
||||||
signal,+18885553333,Charlie,Third message,,,
|
|
||||||
```
|
```
|
||||||
|
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,,,,
|
||||||
|
```
|
||||||
|
|
||||||
|
A per-row `delay` value takes precedence over the global `--delay` flag for
|
||||||
|
that row.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -289,6 +352,7 @@ signal,+18885553333,Charlie,Third message,,,
|
|||||||
|
|
||||||
- **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.
|
- **Empty message:** Rows without a `message` are skipped and reported.
|
||||||
|
- **Missing attachment:** If a named file cannot be found, a warning is printed and the message is sent without the attachment.
|
||||||
- **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.
|
||||||
|
|
||||||
@ -313,4 +377,21 @@ After processing all rows, a summary is printed:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*For support or issues, see https://xkcd.com/627/*
|
## 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*For support or issues, see <https://xkcd.com/627/>*
|
||||||
|
|||||||
410
sendmsg
410
sendmsg
@ -3,29 +3,31 @@
|
|||||||
sendmsg — Universal message sender for Signal and SMS/iMessage.
|
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!"
|
||||||
sendmsg --sms --to +18005551212 --text "Hey" --file ~/photo.png
|
sendmsg --sms --to +18005551212 --text "Hey" --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 --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 --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
|
||||||
|
|
||||||
CSV format (messages.csv):
|
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,,,,-1
|
signal,group.ZzBHd3NZO...,Group alert,Hi team,,,,
|
||||||
sms,+123****7890,Bob,SMS test,,,~/pic.jpg,
|
sms,+1234567890,Bob,SMS test,,,~/pic.jpg,
|
||||||
signal,+1888222333,Carol,With delay,,,-3
|
signal,+1888222333,Carol,With delay,,,,3
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import base64
|
||||||
import configparser
|
import configparser
|
||||||
import csv
|
import csv
|
||||||
import json
|
import json
|
||||||
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
@ -34,7 +36,8 @@ import urllib.error
|
|||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
VERSION = "4.0.0"
|
VERSION = "4.3.0"
|
||||||
|
|
||||||
CONFIG_PATHS = [
|
CONFIG_PATHS = [
|
||||||
os.path.expanduser("~/.sendmsg.conf"),
|
os.path.expanduser("~/.sendmsg.conf"),
|
||||||
]
|
]
|
||||||
@ -54,23 +57,36 @@ def load_config():
|
|||||||
"signal_rest_url": "http://localhost:8080",
|
"signal_rest_url": "http://localhost:8080",
|
||||||
"signal_default_account": "+1234567890",
|
"signal_default_account": "+1234567890",
|
||||||
}
|
}
|
||||||
config = configparser.ConfigParser()
|
env_vars = {
|
||||||
|
"signal_rest_url": "SIGNAL_REST_URL",
|
||||||
|
"signal_default_account": "SIGNAL_ACCOUNT",
|
||||||
|
}
|
||||||
|
|
||||||
|
config = configparser.ConfigParser()
|
||||||
|
config_path = None
|
||||||
for path in CONFIG_PATHS:
|
for path in CONFIG_PATHS:
|
||||||
if os.path.isfile(path):
|
if os.path.isfile(path):
|
||||||
config.read(path)
|
config.read(path)
|
||||||
|
config_path = path
|
||||||
break
|
break
|
||||||
|
|
||||||
return {
|
resolved = {}
|
||||||
"signal_rest_url": (
|
sources = {}
|
||||||
os.environ.get("SIGNAL_REST_URL")
|
for key, default in defaults.items():
|
||||||
or config.get("settings", "signal_rest_url", fallback=defaults["signal_rest_url"])
|
env_val = os.environ.get(env_vars[key])
|
||||||
),
|
if env_val:
|
||||||
"signal_default_account": (
|
resolved[key] = env_val
|
||||||
os.environ.get("SIGNAL_ACCOUNT")
|
sources[key] = f"env (${env_vars[key]})"
|
||||||
or config.get("settings", "signal_default_account", fallback=defaults["signal_default_account"])
|
elif config.has_option("settings", key):
|
||||||
),
|
resolved[key] = config.get("settings", key)
|
||||||
}
|
sources[key] = f"config ({config_path})"
|
||||||
|
else:
|
||||||
|
resolved[key] = default
|
||||||
|
sources[key] = "default"
|
||||||
|
|
||||||
|
resolved["_sources"] = sources
|
||||||
|
resolved["_config_path"] = config_path
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
# Load config at module level for use by all functions
|
# Load config at module level for use by all functions
|
||||||
@ -83,23 +99,87 @@ SIGNAL_DEFAULT_ACCOUNT = CFG["signal_default_account"]
|
|||||||
# Helpers — reusable without argparse objects (needed for CSV dispatch)
|
# Helpers — reusable without argparse objects (needed for CSV dispatch)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def expand_path(path):
|
||||||
|
"""Expand '~' and environment variables in a file path.
|
||||||
|
|
||||||
|
CSV rows supply literal paths like '~/pic.jpg'; unlike the shell, Python
|
||||||
|
does not expand the tilde, so os.path.isfile('~/pic.jpg') is always False.
|
||||||
|
This caused CSV Signal attachments to be silently dropped.
|
||||||
|
"""
|
||||||
|
if not path:
|
||||||
|
return path
|
||||||
|
return os.path.expanduser(os.path.expandvars(path.strip()))
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_account(account):
|
||||||
|
"""Ensure an account/phone number carries a leading '+'.
|
||||||
|
|
||||||
|
Group IDs (starting with 'group.') are returned unchanged.
|
||||||
|
"""
|
||||||
|
if not account:
|
||||||
|
return account
|
||||||
|
account = account.strip()
|
||||||
|
if account.startswith("group."):
|
||||||
|
return account
|
||||||
|
if not account.startswith("+"):
|
||||||
|
account = "+" + account
|
||||||
|
return account
|
||||||
|
|
||||||
|
|
||||||
|
def is_group_id(value):
|
||||||
|
"""Return True if a recipient value looks like a Signal group ID.
|
||||||
|
|
||||||
|
signal-cli group IDs are either the 'group.<base64>' form used by the
|
||||||
|
REST API, or a raw base64 internal group key. Sending a group message
|
||||||
|
down the 1:1 'recipient' path is what causes the 'Unregistered user
|
||||||
|
<uuid>' errors and the wrong-sender appearance in issue #3, so we treat
|
||||||
|
anything that is not a phone number as a group ID.
|
||||||
|
"""
|
||||||
|
if not value:
|
||||||
|
return False
|
||||||
|
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():
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def send_one_signal(account, recipient, message, group_id=None, attach=None):
|
def send_one_signal(account, recipient, message, group_id=None, attach=None):
|
||||||
"""Send a single Signal message. Returns True on success."""
|
"""Send a single Signal message. Returns True on success."""
|
||||||
payload = {"number": account}
|
account = normalize_account(account)
|
||||||
|
|
||||||
|
payload = {"number": account}
|
||||||
if group_id:
|
if group_id:
|
||||||
payload["recipients"] = [group_id]
|
payload["recipients"] = [group_id]
|
||||||
else:
|
else:
|
||||||
payload["recipient"] = recipient
|
payload["recipients"] = [recipient]
|
||||||
|
|
||||||
if message:
|
if message:
|
||||||
payload["message"] = message
|
payload["message"] = message
|
||||||
|
|
||||||
# Attachments
|
# Expand '~' / env vars; CSV supplies literal paths the shell never saw.
|
||||||
if attach and os.path.isfile(attach):
|
attach = expand_path(attach) if attach else None
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
has_attachment = bool(attach)
|
||||||
|
if not message and not has_attachment:
|
||||||
|
print(
|
||||||
|
f"❌ Signal send skipped: no message text and no valid attachment "
|
||||||
|
f"(recipient: {group_id or recipient})",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if has_attachment:
|
||||||
result = signal_rest_post("/v2/send", payload=payload, files=[attach])
|
result = signal_rest_post("/v2/send", payload=payload, files=[attach])
|
||||||
elif message and not group_id:
|
|
||||||
result = signal_rest_post("/v2/send", payload=payload)
|
|
||||||
else:
|
else:
|
||||||
result = signal_rest_post("/v2/send", payload=payload)
|
result = signal_rest_post("/v2/send", payload=payload)
|
||||||
|
|
||||||
@ -109,17 +189,17 @@ def send_one_signal(account, recipient, message, group_id=None, attach=None):
|
|||||||
def send_one_sms(to, message, service=None, file=None):
|
def send_one_sms(to, message, service=None, 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]
|
cmd = ["imsg", "send", "--to", to]
|
||||||
|
|
||||||
if service:
|
if service:
|
||||||
cmd.extend(["--service", service])
|
cmd.extend(["--service", service])
|
||||||
|
|
||||||
cmd.extend(["--text", message])
|
cmd.extend(["--text", message])
|
||||||
|
if file:
|
||||||
if file and os.path.isfile(file):
|
file = expand_path(file)
|
||||||
cmd.extend(["--file", file])
|
if os.path.isfile(file):
|
||||||
|
cmd.extend(["--file", file])
|
||||||
|
else:
|
||||||
|
print(f"⚠️ Warning: file not found, skipping attachment: {file}", file=sys.stderr)
|
||||||
|
|
||||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
service_label = service.upper() if service else "AUTO"
|
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_label})")
|
||||||
@ -134,54 +214,42 @@ def send_one_sms(to, message, service=None, file=None):
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def signal_rest_post(endpoint, payload=None, files=None):
|
def signal_rest_post(endpoint, payload=None, files=None):
|
||||||
"""Post JSON or multipart form data to the signal-cli-rest-api.
|
"""Post a JSON request to the signal-cli-rest-api.
|
||||||
|
|
||||||
|
The /v2/send endpoint expects a single JSON body. Attachments are NOT
|
||||||
|
sent as multipart/form-data — they must be base64-encoded and placed in
|
||||||
|
the "base64_attachments" array, each as a data URI of the form
|
||||||
|
'data:<mime>;filename=<name>;base64,<data>'. Sending multipart produces
|
||||||
|
HTTP 400 "invalid request".
|
||||||
|
|
||||||
Returns the parsed JSON response dict, or None on failure.
|
Returns the parsed JSON response dict, or None on failure.
|
||||||
"""
|
"""
|
||||||
url = f"{SIGNAL_REST_URL.rstrip('/')}{endpoint}"
|
url = f"{SIGNAL_REST_URL.rstrip('/')}{endpoint}"
|
||||||
|
|
||||||
if files is not None and files:
|
payload = dict(payload) if payload else {}
|
||||||
# Multipart form data for attachments
|
|
||||||
boundary = "----sendmsg-boundary"
|
|
||||||
body_parts = []
|
|
||||||
|
|
||||||
if payload:
|
|
||||||
for key, value in payload.items():
|
|
||||||
body_parts.append(
|
|
||||||
f"--{boundary}\r\n"
|
|
||||||
f'Content-Disposition: form-data; name="{key}"\r\n\r\n'
|
|
||||||
f"{value}\r\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
if files:
|
||||||
|
b64_attachments = []
|
||||||
for fpath in files:
|
for fpath in files:
|
||||||
|
fpath = expand_path(fpath)
|
||||||
if not os.path.isfile(fpath):
|
if not os.path.isfile(fpath):
|
||||||
print(f"⚠️ Warning: attachment not found: {fpath}", file=sys.stderr)
|
print(f"⚠️ Warning: attachment not found: {fpath}", file=sys.stderr)
|
||||||
continue
|
continue
|
||||||
with open(fpath, "rb") as fh:
|
with open(fpath, "rb") as fh:
|
||||||
content = fh.read()
|
encoded = base64.b64encode(fh.read()).decode("ascii")
|
||||||
fname = os.path.basename(fpath)
|
fname = os.path.basename(fpath)
|
||||||
body_parts.append(
|
mime = mimetypes.guess_type(fpath)[0] or "application/octet-stream"
|
||||||
f"--{boundary}\r\n"
|
# Data-URI form preserves both MIME type and filename.
|
||||||
f"Content-Disposition: form-data; name=\"attachment\"; filename=\"{fname}\"\r\n"
|
b64_attachments.append(f"data:{mime};filename={fname};base64,{encoded}")
|
||||||
f"Content-Type: application/octet-stream\r\n\r\n"
|
|
||||||
)
|
|
||||||
body_parts.append(content)
|
|
||||||
body_parts.append(b"\r\n")
|
|
||||||
|
|
||||||
body_parts.append(f"--{boundary}--\r\n".encode("utf-8"))
|
if b64_attachments:
|
||||||
full_body = b"".join(body_parts)
|
payload["base64_attachments"] = b64_attachments
|
||||||
|
|
||||||
headers = {
|
body = json.dumps(payload).encode("utf-8")
|
||||||
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
headers = {"Content-Type": "application/json"}
|
||||||
}
|
full_body = body
|
||||||
else:
|
|
||||||
# JSON payload
|
|
||||||
body = json.dumps(payload).encode("utf-8") if payload else b"{}"
|
|
||||||
headers = {"Content-Type": "application/json"}
|
|
||||||
full_body = body
|
|
||||||
|
|
||||||
req = urllib.request.Request(url, data=full_body, headers=headers, method="POST")
|
req = urllib.request.Request(url, data=full_body, headers=headers, method="POST")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
resp_data = resp.read().decode("utf-8")
|
resp_data = resp.read().decode("utf-8")
|
||||||
@ -194,7 +262,7 @@ def signal_rest_post(endpoint, payload=None, files=None):
|
|||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
return result
|
return result
|
||||||
return None
|
return {}
|
||||||
except urllib.error.HTTPError as e:
|
except urllib.error.HTTPError as e:
|
||||||
body = e.read().decode("utf-8", errors="replace")
|
body = e.read().decode("utf-8", errors="replace")
|
||||||
print(
|
print(
|
||||||
@ -236,59 +304,58 @@ def cmd_signal(args):
|
|||||||
if not args.to and not args.recipients:
|
if not args.to and not args.recipients:
|
||||||
print("❌ Error: --to <phone> is required for direct messages, or use --recipients for groups.", file=sys.stderr)
|
print("❌ Error: --to <phone> is required for direct messages, or use --recipients for groups.", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
if not args.text and not getattr(args, "attach", None) and not args.recipients:
|
|
||||||
|
attach_files = getattr(args, "attach", None) or getattr(args, "file", 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)
|
print("❌ Error: --text <message> or --attach <file> is required.", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
account = args.account or SIGNAL_DEFAULT_ACCOUNT
|
account = normalize_account(args.account or SIGNAL_DEFAULT_ACCOUNT)
|
||||||
if not account.startswith("+"):
|
|
||||||
account = "+" + account
|
|
||||||
|
|
||||||
# Build the JSON payload for /v2/send
|
# Build the JSON payload for /v2/send
|
||||||
payload = {"number": account}
|
payload = {"number": account}
|
||||||
|
|
||||||
if args.recipients:
|
if args.recipients:
|
||||||
# Send to a Signal group — use recipients array with group. prefix
|
# Send to a Signal group — use recipients array with the group ID.
|
||||||
payload["recipients"] = [args.recipients]
|
payload["recipients"] = [args.recipients]
|
||||||
else:
|
else:
|
||||||
# Send to individual recipients (string)
|
# Send to an individual recipient. The REST API accepts a
|
||||||
payload["recipient"] = args.to[0]
|
# 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]]
|
||||||
|
|
||||||
# Message text (join multiple --text args)
|
# Message text (join multiple --text args)
|
||||||
if args.text:
|
if args.text:
|
||||||
payload["message"] = "\n".join(args.text)
|
payload["message"] = "\n".join(args.text)
|
||||||
|
|
||||||
# Attachments
|
# Attachments
|
||||||
attach_files = getattr(args, "attach", None)
|
|
||||||
|
|
||||||
if attach_files and len(attach_files) > 0:
|
if attach_files and len(attach_files) > 0:
|
||||||
if len(attach_files) == 1:
|
if len(attach_files) == 1:
|
||||||
# Single file — send as multipart with message
|
# Single file — send as multipart with the message.
|
||||||
result = signal_rest_post("/v2/send", payload=payload, files=attach_files)
|
result = signal_rest_post("/v2/send", payload=payload, files=attach_files)
|
||||||
else:
|
else:
|
||||||
# Multiple files: send message first, then each attachment separately
|
# Multiple files: send the text first, then each attachment.
|
||||||
multi_payload = {"number": account, "message": payload.get("message", "")}
|
multi_payload = {"number": account, "message": payload.get("message", "")}
|
||||||
if args.recipients:
|
multi_payload["recipients"] = payload["recipients"]
|
||||||
multi_payload["recipients"] = [args.recipients]
|
|
||||||
else:
|
# Only send a standalone text message if there is actual text.
|
||||||
multi_payload["recipient"] = args.to[0]
|
result = None
|
||||||
result = signal_rest_post("/v2/send", payload=multi_payload)
|
if multi_payload["message"]:
|
||||||
if result is None:
|
result = signal_rest_post("/v2/send", payload=multi_payload)
|
||||||
sys.exit(1)
|
|
||||||
# Then send each attachment
|
|
||||||
for fpath in attach_files:
|
|
||||||
att_payload = {"number": account}
|
|
||||||
if args.recipients:
|
|
||||||
att_payload["recipients"] = [args.recipients]
|
|
||||||
else:
|
|
||||||
att_payload["recipient"] = args.to[0]
|
|
||||||
result = signal_rest_post("/v2/send", payload=att_payload, files=[fpath])
|
|
||||||
if result is None:
|
if result is None:
|
||||||
print(f"⚠️ Attachment failed: {fpath}", file=sys.stderr)
|
sys.exit(1)
|
||||||
elif args.text and not args.recipients:
|
|
||||||
result = signal_rest_post("/v2/send", payload=payload)
|
# 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:
|
else:
|
||||||
# Group message or other case
|
# Text-only send (direct or group).
|
||||||
result = signal_rest_post("/v2/send", payload=payload)
|
result = signal_rest_post("/v2/send", payload=payload)
|
||||||
|
|
||||||
if result is not None:
|
if result is not None:
|
||||||
@ -298,8 +365,7 @@ def cmd_signal(args):
|
|||||||
print(f"✅ Signal message sent to {args.to[0]}")
|
print(f"✅ Signal message sent to {args.to[0]}")
|
||||||
else:
|
else:
|
||||||
print("✅ Signal message sent")
|
print("✅ Signal message sent")
|
||||||
if args.verbose and result:
|
if args.verbose and isinstance(result, dict):
|
||||||
# Print any extra details from the API response
|
|
||||||
for key, value in result.items():
|
for key, value in result.items():
|
||||||
if key not in ("success",):
|
if key not in ("success",):
|
||||||
print(f" {key}: {value}")
|
print(f" {key}: {value}")
|
||||||
@ -318,21 +384,21 @@ def cmd_sms(args):
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
cmd = ["imsg", "send", "--to", args.to[0]]
|
cmd = ["imsg", "send", "--to", args.to[0]]
|
||||||
|
|
||||||
if args.service:
|
if args.service:
|
||||||
cmd.extend(["--service", args.service])
|
cmd.extend(["--service", args.service])
|
||||||
|
|
||||||
msg_text = "\n".join(args.text)
|
msg_text = "\n".join(args.text)
|
||||||
cmd.extend(["--text", msg_text])
|
cmd.extend(["--text", msg_text])
|
||||||
|
|
||||||
if getattr(args, "file", None):
|
attach_files = getattr(args, "file", None) or getattr(args, "attach", None)
|
||||||
for f in args.file:
|
if attach_files:
|
||||||
|
for f in attach_files:
|
||||||
|
f = expand_path(f)
|
||||||
if not os.path.isfile(f):
|
if not os.path.isfile(f):
|
||||||
print(f"⚠️ Warning: file not found: {f}", file=sys.stderr)
|
print(f"⚠️ Warning: file not found, skipping: {f}", file=sys.stderr)
|
||||||
|
continue
|
||||||
cmd.extend(["--file", f])
|
cmd.extend(["--file", f])
|
||||||
|
|
||||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
service_label = args.service.upper() if args.service else "AUTO"
|
service_label = args.service.upper() if args.service else "AUTO"
|
||||||
print(f"✅ SMS/iMessage sent to {args.to[0]} (service: {service_label})")
|
print(f"✅ SMS/iMessage sent to {args.to[0]} (service: {service_label})")
|
||||||
@ -340,21 +406,23 @@ def cmd_sms(args):
|
|||||||
print(f"❌ SMS send failed (exit {result.returncode})", file=sys.stderr)
|
print(f"❌ SMS send failed (exit {result.returncode})", file=sys.stderr)
|
||||||
if result.stderr:
|
if result.stderr:
|
||||||
print(f" {result.stderr.strip()}", file=sys.stderr)
|
print(f" {result.stderr.strip()}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
def cmd_csv(args):
|
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, delay
|
||||||
"""
|
"""
|
||||||
filepath = args.csv
|
filepath = args.csv
|
||||||
|
|
||||||
if not os.path.isfile(filepath):
|
if not os.path.isfile(filepath):
|
||||||
print(f"❌ Error: file not found: {filepath}", file=sys.stderr)
|
print(f"❌ Error: file not found: {filepath}", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
delay = float(args.delay) if args.delay else 0.0
|
# Global delay from --delay applies between every send unless a row
|
||||||
|
# specifies its own delay (which takes precedence).
|
||||||
|
global_delay = float(args.delay) if args.delay else 0.0
|
||||||
|
|
||||||
with open(filepath, newline="", encoding="utf-8") as csvfile:
|
with open(filepath, newline="", encoding="utf-8") as csvfile:
|
||||||
reader = csv.DictReader(csvfile)
|
reader = csv.DictReader(csvfile)
|
||||||
@ -376,20 +444,20 @@ def cmd_csv(args):
|
|||||||
name = row.get("name", "").strip()
|
name = row.get("name", "").strip()
|
||||||
|
|
||||||
# Skip blank rows
|
# Skip blank rows
|
||||||
if not method and not message:
|
if not method and not message and not to:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if not method:
|
if not method:
|
||||||
method = "signal" # default
|
method = "signal" # default
|
||||||
|
|
||||||
if method not in ("signal", "sms"):
|
if method not in ("signal", "sms"):
|
||||||
print(f"⚠️ Row {i}: unknown method '{method}' — skipping", file=sys.stderr)
|
print(f"⚠️ Row {i}: unknown method '{method}' — skipping", file=sys.stderr)
|
||||||
failed += 1
|
failed += 1
|
||||||
errors.append(f"Row {i}: unknown method '{method}'")
|
errors.append(f"Row {i}: unknown method '{method}'")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if not message:
|
if not message:
|
||||||
print(f"⚠️ Row {i}: empty message — skipping", file=sys.stderr)
|
print(f"⚠️ Row {i}: empty message — skipping", file=sys.stderr)
|
||||||
failed += 1
|
failed += 1
|
||||||
errors.append(f"Row {i}: empty message")
|
errors.append(f"Row {i}: empty message")
|
||||||
continue
|
continue
|
||||||
@ -398,14 +466,21 @@ def cmd_csv(args):
|
|||||||
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() or None
|
||||||
file_path = row.get("file", "").strip() or None
|
file_path = row.get("file", "").strip() or None
|
||||||
delay_str = row.get("delay", "0").strip()
|
delay_str = row.get("delay", "").strip()
|
||||||
row_delay = float(delay_str) if delay_str else 0.0
|
try:
|
||||||
|
row_delay = float(delay_str) if delay_str else None
|
||||||
|
except ValueError:
|
||||||
|
print(f"⚠️ Row {i}: invalid delay '{delay_str}' — ignoring", file=sys.stderr)
|
||||||
|
row_delay = None
|
||||||
|
|
||||||
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}")
|
print(f"[{i}/{total}] {method.upper()} → {label}")
|
||||||
|
|
||||||
if method == "signal":
|
if method == "signal":
|
||||||
group_id = to if to.startswith("group.") else None
|
# Detect groups robustly (issue #3): raw base64 group IDs do NOT
|
||||||
|
# start with 'group.' and were previously sent down the 1:1
|
||||||
|
# recipient path, producing 'Unregistered user <uuid>' errors.
|
||||||
|
group_id = to if is_group_id(to) else None
|
||||||
result = send_one_signal(
|
result = send_one_signal(
|
||||||
account=account,
|
account=account,
|
||||||
recipient=to,
|
recipient=to,
|
||||||
@ -413,21 +488,13 @@ def cmd_csv(args):
|
|||||||
group_id=group_id,
|
group_id=group_id,
|
||||||
attach=file_path,
|
attach=file_path,
|
||||||
)
|
)
|
||||||
elif method == "sms":
|
else: # sms
|
||||||
result = send_one_sms(
|
result = send_one_sms(
|
||||||
to=to,
|
to=to,
|
||||||
message=message,
|
message=message,
|
||||||
service=service,
|
service=service,
|
||||||
file=file_path,
|
file=file_path,
|
||||||
)
|
)
|
||||||
if result:
|
|
||||||
success += 1
|
|
||||||
else:
|
|
||||||
failed += 1
|
|
||||||
errors.append(f"Row {i}: SMS failed")
|
|
||||||
if row_delay > 0:
|
|
||||||
time.sleep(row_delay)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if result:
|
if result:
|
||||||
success += 1
|
success += 1
|
||||||
@ -435,9 +502,10 @@ def cmd_csv(args):
|
|||||||
failed += 1
|
failed += 1
|
||||||
errors.append(f"Row {i}: failed to send")
|
errors.append(f"Row {i}: failed to send")
|
||||||
|
|
||||||
# Delay between sends (only if explicitly set in row)
|
# Per-row delay overrides global; otherwise use the global --delay.
|
||||||
if row_delay > 0:
|
effective_delay = row_delay if row_delay is not None else global_delay
|
||||||
time.sleep(row_delay)
|
if effective_delay > 0:
|
||||||
|
time.sleep(effective_delay)
|
||||||
|
|
||||||
# Summary
|
# Summary
|
||||||
print()
|
print()
|
||||||
@ -458,6 +526,46 @@ def cmd_csv(args):
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_show_config(args):
|
||||||
|
"""Report the resolved configuration and where each value came from."""
|
||||||
|
sources = CFG.get("_sources", {})
|
||||||
|
config_path = CFG.get("_config_path")
|
||||||
|
|
||||||
|
print("=" * 50)
|
||||||
|
print("⚙️ sendmsg configuration")
|
||||||
|
print("=" * 50)
|
||||||
|
print(f" Version: {VERSION}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
if config_path:
|
||||||
|
print(f" Config file: {config_path} (found)")
|
||||||
|
else:
|
||||||
|
searched = ", ".join(CONFIG_PATHS)
|
||||||
|
print(f" Config file: none found (searched: {searched})")
|
||||||
|
print()
|
||||||
|
|
||||||
|
print(f" signal_rest_url: {SIGNAL_REST_URL}")
|
||||||
|
print(f" └─ source: {sources.get('signal_rest_url', 'unknown')}")
|
||||||
|
print()
|
||||||
|
print(f" signal_default_account: {SIGNAL_DEFAULT_ACCOUNT}")
|
||||||
|
print(f" └─ source: {sources.get('signal_default_account', 'unknown')}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
print(" Resolution order: env var > config file > built-in default")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
# Optionally probe whether the Signal REST API is reachable.
|
||||||
|
if getattr(args, "verbose", False):
|
||||||
|
print()
|
||||||
|
print(" Checking Signal REST API reachability...")
|
||||||
|
result = signal_rest_get("/v1/accounts")
|
||||||
|
if result is None:
|
||||||
|
print(f" ❌ Not reachable at {SIGNAL_REST_URL}")
|
||||||
|
else:
|
||||||
|
print(f" ✅ Reachable at {SIGNAL_REST_URL}")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
|
||||||
def cmd_list_signal(args):
|
def cmd_list_signal(args):
|
||||||
"""List linked Signal accounts via the REST API."""
|
"""List linked Signal accounts via the REST API."""
|
||||||
result = signal_rest_get("/v1/accounts")
|
result = signal_rest_get("/v1/accounts")
|
||||||
@ -491,18 +599,19 @@ def cmd_link_signal(args):
|
|||||||
name = args.name or "automation"
|
name = args.name or "automation"
|
||||||
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:
|
||||||
print("❌ Failed to initiate device linking.", file=sys.stderr)
|
print("❌ Failed to initiate device linking.", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
sgnl_url = result.get("url", result.get("linkUrl", ""))
|
sgnl_url = ""
|
||||||
|
if isinstance(result, dict):
|
||||||
|
sgnl_url = result.get("url", result.get("linkUrl", ""))
|
||||||
if not sgnl_url:
|
if not sgnl_url:
|
||||||
print("❌ No sgnl:// URL returned. API response:", file=sys.stderr)
|
print("❌ No sgnl:// URL returned. API response:", file=sys.stderr)
|
||||||
print(json.dumps(result, indent=2), file=sys.stderr)
|
print(json.dumps(result, indent=2), file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
print(f"🔗 Scan this QR code or visit this URL to link your device:")
|
print("🔗 Scan this QR code or visit this URL to link your device:")
|
||||||
print(f" {sgnl_url}")
|
print(f" {sgnl_url}")
|
||||||
print()
|
print()
|
||||||
print("Waiting for confirmation (Ctrl+C to cancel)...")
|
print("Waiting for confirmation (Ctrl+C to cancel)...")
|
||||||
@ -515,7 +624,7 @@ def cmd_link_signal(args):
|
|||||||
print(f"✅ Device linked! Account: {result}")
|
print(f"✅ Device linked! Account: {result}")
|
||||||
return
|
return
|
||||||
|
|
||||||
print("⚠️ Linking timed out. Please try again.", file=sys.stderr)
|
print("⚠️ Linking timed out. Please try again.", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@ -530,8 +639,8 @@ 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 --sms --to +18005551212 --text 'Text via SMS'\n"
|
||||||
" %(prog)s --sms --to +18005551212 --text 'Hey' --file ~/pic.jpg\n"
|
" %(prog)s --sms --to +18005551212 --text 'Hey' --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 --list-signal\n"
|
" %(prog)s --list-signal\n"
|
||||||
@ -551,7 +660,7 @@ def main():
|
|||||||
|
|
||||||
parser.add_argument("-V", "--version", action="version", version=f"%(prog)s {VERSION}")
|
parser.add_argument("-V", "--version", action="version", version=f"%(prog)s {VERSION}")
|
||||||
|
|
||||||
# Method (mutually exclusive)
|
# Method
|
||||||
method = parser.add_argument_group("Messaging method (choose one)")
|
method = parser.add_argument_group("Messaging method (choose one)")
|
||||||
method.add_argument("--signal", action="store_true", help="Send via Signal")
|
method.add_argument("--signal", action="store_true", help="Send via Signal")
|
||||||
method.add_argument("--sms", action="store_true", help="Send via SMS/iMessage (macOS Messages)")
|
method.add_argument("--sms", action="store_true", help="Send via SMS/iMessage (macOS Messages)")
|
||||||
@ -560,20 +669,22 @@ def main():
|
|||||||
# Common options
|
# Common options
|
||||||
parser.add_argument("--to", nargs="+", metavar="PHONE", help="Recipient phone number(s)")
|
parser.add_argument("--to", nargs="+", metavar="PHONE", help="Recipient phone number(s)")
|
||||||
parser.add_argument("--text", nargs="+", metavar="MSG", help="Message text (repeat for multiple)")
|
parser.add_argument("--text", nargs="+", metavar="MSG", help="Message text (repeat for multiple)")
|
||||||
parser.add_argument("--file", "--attach", nargs="+", metavar="PATH", help="Attachment file path(s)")
|
parser.add_argument("--file", "--attach", nargs="+", metavar="PATH", dest="file",
|
||||||
|
help="Attachment file path(s)")
|
||||||
parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output")
|
parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output")
|
||||||
|
|
||||||
# Signal-specific
|
# Signal-specific
|
||||||
signal_grp = parser.add_argument_group("Signal options")
|
signal_grp = parser.add_argument_group("Signal options")
|
||||||
signal_grp.add_argument("--account", metavar="PHONE", help="Signal account phone number to use")
|
signal_grp.add_argument("--account", metavar="PHONE", help="Signal account phone number to use")
|
||||||
signal_grp.add_argument("--recipients", metavar="RECIPIENTS", help="Send to Signal group ID (no --to needed)")
|
signal_grp.add_argument("--recipients", metavar="RECIPIENTS", help="Send to Signal group ID (no --to needed)")
|
||||||
signal_grp.add_argument("--voice", action="store_true", help="Send as voice note (Signal only)")
|
signal_grp.add_argument("--name", metavar="NAME", help="Device name for Signal linking (default: 'automation')")
|
||||||
|
|
||||||
# Management commands
|
# Management commands
|
||||||
mgmt = parser.add_argument_group("Management commands")
|
mgmt = parser.add_argument_group("Management commands")
|
||||||
mgmt.add_argument("--list-signal", action="store_true", help="List linked Signal accounts")
|
mgmt.add_argument("--list-signal", action="store_true", help="List linked Signal accounts")
|
||||||
mgmt.add_argument("--link-signal", action="store_true", help="Link a new device to your Signal account")
|
mgmt.add_argument("--link-signal", action="store_true", help="Link a new device to your Signal account")
|
||||||
signal_grp.add_argument("--name", metavar="NAME", help="Device name for Signal linking (default: 'automation')")
|
mgmt.add_argument("--show-config", action="store_true",
|
||||||
|
help="Print resolved configuration and where each value came from")
|
||||||
|
|
||||||
# SMS-specific
|
# SMS-specific
|
||||||
sms_grp = parser.add_argument_group("SMS/iMessage options")
|
sms_grp = parser.add_argument_group("SMS/iMessage options")
|
||||||
@ -585,14 +696,17 @@ def main():
|
|||||||
|
|
||||||
args = parser.parse_args()
|
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
|
# Validate: must have a method or management command
|
||||||
if not any([args.signal, args.sms, args.csv, args.list_signal, args.link_signal]):
|
if not any([args.signal, args.sms, args.csv, args.list_signal, args.link_signal, args.show_config]):
|
||||||
parser.print_help()
|
parser.print_help()
|
||||||
print("\n❌ Error: Specify --signal, --sms, --csv, --list-signal, or --link-signal", 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([args.signal, args.sms]) > 1:
|
if sum([bool(args.signal), bool(args.sms), bool(args.csv)]) > 1:
|
||||||
print("❌ Error: Choose only one method (--signal or --sms).", file=sys.stderr)
|
print("❌ Error: Choose only one method (--signal, --sms, or --csv).", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# Set defaults
|
# Set defaults
|
||||||
@ -610,6 +724,8 @@ def main():
|
|||||||
cmd_list_signal(args)
|
cmd_list_signal(args)
|
||||||
elif args.link_signal:
|
elif args.link_signal:
|
||||||
cmd_link_signal(args)
|
cmd_link_signal(args)
|
||||||
|
elif args.show_config:
|
||||||
|
cmd_show_config(args)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user