Compare commits

..

13 Commits
v4.3.0 ... main

Author SHA1 Message Date
Daniel Spisak
c5a4820f1c
Merge pull request #10 from docwho76/v5.2.0
add test suite, deal with commas
2026-07-15 11:44:26 -07:00
Daniel Spisak
7275a973e4 add test suite, deal with commas 2026-07-15 11:41:58 -07:00
Daniel Spisak
e2b44811fd
Merge pull request #9 from docwho76/v5.1.1-doc-fixes-v2
fix readme formatting errors v2
2026-06-13 02:03:42 -07:00
Daniel Spisak
be4225b8c4 fix readme formatting errors v2 2026-06-13 02:02:48 -07:00
Daniel Spisak
6db49e60e0
Merge pull request #8 from docwho76/v5.1.1-doc-fixes
fix readme formatting errors
2026-06-13 01:48:42 -07:00
Daniel Spisak
4931658f9a fix readme formatting errors 2026-06-13 01:47:59 -07:00
Daniel Spisak
bb57b78bed
Merge pull request #7 from docwho76/v5.1.1-docs-updates
V5.1.1 docs updates
2026-06-13 01:45:23 -07:00
Daniel Spisak
3454908def readme further update and edits 2026-06-13 01:43:59 -07:00
Daniel Spisak
7b43087a0b bump minor version for doc updates only 2026-06-13 01:37:46 -07:00
Daniel Spisak
83d70b6b23
Merge pull request #6 from docwho76/v5.1.0-turd-polishing
more bug fixes and updates
2026-06-13 00:25:14 -07:00
Daniel Spisak
9a598f6aa4 more bug fixes and updates 2026-06-13 00:21:47 -07:00
Daniel Spisak
ec6312e021
Merge pull request #5 from docwho76/v5.0.0-polish-and-fixes
v5.0.0 polish and fixes, add voice msgs for signal
2026-06-12 23:39:22 -07:00
Daniel Spisak
73c143d38f v5.0.0 polish and fixes, add voice msgs for signal 2026-06-12 23:37:12 -07:00
5 changed files with 1518 additions and 292 deletions

86
CHANGELOG.md Normal file
View File

@ -0,0 +1,86 @@
# Changelog
All notable changes to `sendmsg` are documented here.
## 5.2.0
### Added
- **`--list-groups`:** Lists the Signal groups the account belongs to, with their names, IDs, member counts, and blocked status (via `GET /v1/groups/{number}`). This is the sanctioned way to discover the group IDs that `--recipients` and CSV rows expect. Errors out clearly when no real account is configured.
- **Test suite and CI:** A pytest suite (`tests/`) covering phone/group detection, path expansion, attachment validation, Signal payload construction, CSV dispatch (BOM, blank rows, skips, dry run, `--json`), and CLI argument validation, plus a GitHub Actions workflow running it on Python 3.103.13.
- **Commas in CSV messages:** Quoted message fields (`"Hi, there"`) are parsed per standard CSV rules as before, and messages with *unquoted* commas are now repaired automatically — the extra fragments are merged back into the `message` column (columns before and after it are preserved) with a warning recommending quoting. Previously an unquoted comma silently shifted every subsequent column, leaking message text into `account`, `service`, etc. Rows with fewer fields than the header are also tolerated (missing trailing columns are treated as empty).
- **Dry-run validation:** `--dry-run` now flags rows with missing recipients and verifies that `file`/`voice` paths exist (reported as `[NOT FOUND]` and listed in the summary notes), so problems surface before a real run.
### Fixed
- **Voice messages: removed the non-existent `voice` API flag.** signal-cli-rest-api's `/v2/send` has no voice-note field; the flag sent since 5.0.0 was silently ignored and the audio was always delivered as a regular audio attachment. The payload and documentation now reflect reality. (`--voice` still works — the audio is sent as its own message — but whether it renders as a playable voice note depends on the receiving client.)
- **UTF-8 BOM no longer misroutes CSV rows.** CSVs are read with `utf-8-sig`. Previously a BOM (written by Excel and many editors) corrupted the first header to `\ufeffmethod`, making every row's method appear empty — which defaulted to `signal` and silently sent explicit `sms` rows via Signal.
- **Formatted phone numbers are no longer misdetected as group IDs.** Spaces, dashes, dots, and parentheses are stripped before phone-vs-group detection and before sending, so `+1 (800) 555-1212` and `555-867-5309` are routed as direct messages in E.164 form instead of down the group path.
- **`--delay abc` no longer crashes.** The global `--delay` is parsed by argparse as a float, producing a clean usage error instead of a `ValueError` traceback.
- **`--json` output is machine-parseable.** Per-row progress lines now go to stderr when `--json` is set, so stdout contains only the JSON summary and can be piped to `jq` etc. The summary also gains a `blank` count.
- **`--sms` sends to every `--to` recipient.** Previously only the first was sent and the rest were silently dropped; the SMS path now fans out like the Signal path and recipients are `+`-normalized.
- **Rows without a recipient are skipped and reported** instead of being POSTed with an empty recipient and failing with a confusing API error.
- **CSV summary accounting is exact.** Blank rows are counted and reported (`blank` in `--json`, a `Blank` line in the text summary), and the blank/actionable test is shared between the pre-flight count and the row loop, so a row containing only a `file` value can no longer be counted as actionable yet silently skipped. `Total = blank + success + skipped + failed` always holds.
- **SMS retries only on timeout.** Non-zero `imsg` exits (bad recipient, Messages not signed in, ...) are permanent and now fail fast instead of being retried three times with backoff.
- **`--link-signal` timeout exits non-zero**, so scripts can detect a failed link.
- **`--list-signal` and other GET calls now retry** transient failures (429/5xx, network errors) with backoff, matching the send path.
## 5.1.1
### Added
- **Updated README.md:** Exposes docker compose sample config for signal-rest-api setup along with notes on Traefik and Pullio usage and integration
## 5.1.0
### Added
- **Multiple recipients per Signal send:** `--recipients` now accepts more than one value, and `--to` accepts multiple numbers, so a single invocation can fan out to several groups and/or direct recipients. Each target is sent independently and reported individually; the command exits non-zero if any target fails.
- **`--json` CSV summary:** Emits a machine-readable JSON summary (totals, per-bucket counts, and notes) for unattended runs, as an alternative to the formatted text summary.
- **SMS timeout and retry:** `imsg` invocations now run with a 60-second timeout and are retried up to 3 times with backoff on timeout or transient failure, matching the resilience of the Signal REST path. Previously a hung Messages app could stall an entire batch and SMS sends had no retry.
- **Startup configuration warnings:** A `signal_rest_url` that is not a well-formed `http(s)://` URL now warns at startup, and attempting a Signal send from the built-in placeholder account (`+1234567890`) warns that no real account is configured.
### Changed
- **Skip vs. fail accounting corrected.** Intentional skips — unknown `method`, missing/invalid SMS `service`, `voice` on an SMS row, and empty messages — are now counted under **Skipped** rather than **Failed**. The process exit code is `1` only when a genuine send fails, so deliberate skips no longer cause unattended runs to report failure.
- **Dry runs no longer report sends as successful.** A `--dry-run` now reports a separate "would send" count instead of incrementing the success total for rows that were never sent.
- Empty-message skip notes now distinguish SMS rows (which always require text) from Signal rows (which may be attachment- or voice-only).
### Fixed
- **`--link-signal` false success:** The account snapshot used for new-link detection now tolerates all response shapes returned by the REST API (bare list, `{"accounts": [...]}`, and `{"number": ...}`). Previously a dict-shaped response produced an empty baseline, so any pre-existing account could be misreported as a newly linked device. Account listing and link detection now share one shape-tolerant helper.
- The `--help` epilog and module docstring CSV column lists now include the `voice` column, matching the documented CSV format and the code.
## 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.

274
README.md
View File

@ -4,7 +4,7 @@
`sendmsg` is a command-line tool for sending messages via the [Signal REST API](https://github.com/bbernhard/signal-cli-rest-api) or locally via macOS [Messages](https://apps.apple.com/app/messages/id1092291483) (iMessage/SMS) through the [`imsg`] CLI. `sendmsg` is a command-line tool for sending messages via the [Signal REST API](https://github.com/bbernhard/signal-cli-rest-api) or locally via macOS [Messages](https://apps.apple.com/app/messages/id1092291483) (iMessage/SMS) through the [`imsg`] CLI.
It supports individual direct messages, Signal group broadcasts, file attachments, and bulk sending from CSV files — all with a single, consistent interface. It supports individual direct messages, Signal group broadcasts, file attachments, Signal voice messages, and bulk sending from CSV files — all with a single, consistent interface.
--- ---
@ -15,6 +15,7 @@ It supports individual direct messages, Signal group broadcasts, file attachment
- [Installation](#installation) - [Installation](#installation)
- [Usage](#usage) - [Usage](#usage)
* [Signal Messages](#signal-messages) * [Signal Messages](#signal-messages)
* [Voice Messages](#voice-messages)
* [SMS / iMessage](#sms--imessage) * [SMS / iMessage](#sms--imessage)
* [Bulk Send from CSV](#bulk-send-from-csv) * [Bulk Send from CSV](#bulk-send-from-csv)
* [Management Commands](#management-commands) * [Management Commands](#management-commands)
@ -23,6 +24,7 @@ It supports individual direct messages, Signal group broadcasts, file attachment
- [Examples](#examples) - [Examples](#examples)
- [Error Handling](#error-handling) - [Error Handling](#error-handling)
- [Summary Output](#summary-output) - [Summary Output](#summary-output)
- [Changelog](#changelog)
--- ---
@ -53,7 +55,7 @@ It supports individual direct messages, Signal group broadcasts, file attachment
└─────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────┘
``` ```
**Signal path:** `sendmsg` → JSON HTTP POST → `signal-cli-rest-api` → Docker container → Signal network. Attachments are base64-encoded inline in the JSON request body (the API does not accept multipart uploads). **Signal path:** `sendmsg` → JSON HTTP POST → `signal-cli-rest-api` → Docker container → Signal network. Attachments are base64-encoded inline in the JSON request body (the API does not accept multipart uploads). Voice messages are sent the same way, as an audio attachment in a message of its own — the REST API has no dedicated voice-note flag, so how the audio renders (inline player vs. file) is up to the receiving client.
**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.
--- ---
@ -104,15 +107,52 @@ chmod +x /usr/local/bin/sendmsg
**For Signal:** **For Signal:**
a. **Get signal-cli-rest running in docker compose:**
Put the following config into your docker-compose.yml file (this assumes you are using [traefik](https://github.com/traefik/traefik) to reverse proxy your docker compose services and [pullio](https://hotio.dev/scripts/pullio/) to automate updates)
```
services:
signal-api:
image: bbernhard/signal-cli-rest-api:latest
restart: always
expose:
- "8080"
labels:
- "traefik.http.routers.$NAME.rule=Host(`$SIGNAL_INTERNAL_URL`)"
- "traefik.http.routers.$NAME.entrypoints=websecure"
- "traefik.http.routers.$NAME.tls.certresolver=route53"
- "org.hotio.pullio.notify=true"
- "org.hotio.pullio.update=true"
- "org.hotio.pullio.generic.webhook=$WEBHOOK_URL"
- "org.hotio.pullio.author.avatar=$GRAVATAR_URL"
volumes:
- $DOCKER_STORAGE_DIR:/home/.local/share/signal-cli
environment:
- MODE=native
- AUTO_RECEIVE_SCHEDULE=0 22 * * *
- PUID=$USER_ID
- PGID=$GROUP_ID
- TZ=$TIMEZONE_NAME
```
- _$SIGNAL_INTERNAL_URL_ - The internal URL traefik will server the container from https://example.internal.mydomain.com
- _$WEBHOOK_URL_ - The URL to your IFTTT webhook for Pullio, i.e https://maker.ifttt.com/trigger/Pullio/with/key/test-key
- _$GRAVATAR_URL_ - The URL for your [Gravatar](https://gravatar.com/) icon i.e https://s.gravatar.com/avatar/1234567890
- _$NAME_ - The name you give to this service in traefik
- _$DOCKER_STORAGE_DIR_ - The directory on your docker compose host where the signal containers filesystem lives i.e /opt/docker-data/signal
- _$TIMEZONE_NAME_ - The [Linux Timezone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) name i.e "America/Los_Angeles"
<sub>**Note:** See https://builder.aws.com/content/2tmBo5uCgpwQegRBsdSDmupj5rH/lets-encrypt-dns01-challenge-with-traefik-and-aws-route-53 for examples of using AWS Route53 with traefik to do Lets Encrypt ACME DNS-01 requests for automatic SSL certificate creation with Traefik. Installation of traefik is outside the scope of this document.</sub>
b. **Verify signal-cli-rest-api is running**
``` ```
# Verify signal-cli-rest-api is running
curl -s http://localhost:8080/v1/accounts | python3 -m json.tool curl -s http://localhost:8080/v1/accounts | python3 -m json.tool
``` ```
**For SMS/iMessage:** **For SMS/iMessage:**
Verify imsg is installed and available
``` ```
# Verify imsg is installed and available
imsg send --help imsg send --help
``` ```
@ -137,6 +177,7 @@ Messaging methods (choose one):
Management commands (choose one): Management commands (choose one):
--list-signal List linked Signal accounts --list-signal List linked Signal accounts
--list-groups List Signal groups and their IDs (use --account to pick the account)
--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 --show-config Print resolved configuration and where each value came from
``` ```
@ -158,38 +199,81 @@ 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 message** with `--voice`. The audio is
delivered as an audio attachment in its own message (the Signal REST API has
no dedicated voice-note field, so whether it renders as an inline playable
voice note or as an audio file depends on the recipient's client):
```
# 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
``` ```
# List linked Signal accounts # List linked Signal accounts
sendmsg --list-signal sendmsg --list-signal
# List Signal groups and their IDs (for --recipients and CSV rows)
sendmsg --list-groups
sendmsg --list-groups --account +18885551212
# 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"
@ -205,31 +289,39 @@ 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 message**. 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. - **Commas in the message are fine.** The standard way is to quote the field: `signal,+1888,Alice,"Hi, how are you?",,,,,`. If a message contains *unquoted* commas, `sendmsg` detects that the row has more fields than the header, merges the extra fragments back into the `message` column (keeping the columns before and after it intact), and prints a warning suggesting quoting. Fields that themselves contain quotes follow normal CSV rules (`"She said ""hi"", then left"`).
- Group recipients are auto-detected: any `recipient` that is not a `+`-prefixed phone number is treated as a Signal group ID. - `signal` rows may be **attachment-only** or **voice-only**: if a `file` or `voice` is present, the `message` may be left blank. `sms` rows always require a `message`.
- The `voice` column is **Signal only**. An `sms` row with a `voice` value is skipped and reported, since SMS/iMessage has no voice-note concept.
- A voice note is sent as its own message; if a row has both `voice` and `file`, they are delivered as separate messages.
- Recognized voice formats: `.m4a`, `.aac`, `.ogg`, `.opus`, `.mp3`, `.wav`.
- `sms` rows require a `service` of `imessage` or `sms`; rows with a missing or invalid service are skipped and reported.
- Group recipients are auto-detected: any `recipient` that is not a phone number is treated as a Signal group ID. Bare (no `+`) phone numbers are recognized as numbers, not groups.
- File paths starting with `~` are expanded to the home directory of the user running the script. If a named attachment cannot be found, a warning is printed and the message is still sent without it. - 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 +329,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 +374,7 @@ $ sendmsg --show-config
================================================== ==================================================
⚙️ sendmsg configuration ⚙️ sendmsg configuration
================================================== ==================================================
Version: 4.3.0 Version: 5.1.0
Config file: /Users/you/.sendmsg.conf (found) Config file: /Users/you/.sendmsg.conf (found)
@ -310,17 +405,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 +424,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 +457,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,31 +476,39 @@ 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
================================================== ==================================================
``` ```
> Intentional skips — unknown method, missing/invalid SMS service, voice on
> an SMS row, and empty messages — are counted under **Skipped**, not
> **Failed**. Only genuine send failures count as **Failed**, and the exit
> code is `1` only when something actually failed to send. Add `--json` for a
> machine-readable summary in unattended runs.
--- ---
## Testing
The repository ships with a pytest suite (`tests/test_sendmsg.py`) that runs entirely offline — no Signal container, no macOS, and no real config needed:
```bash
pip install pytest
python3 -m pytest tests/
```
See [TESTING.md](TESTING.md) for details on selecting subsets of tests, how the suite stubs out sends, and how CI runs it.
## 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.
--- ---

98
TESTING.md Normal file
View File

@ -0,0 +1,98 @@
# Testing sendmsg
The test suite lives in `tests/test_sendmsg.py` and covers phone/group
detection, path expansion, attachment validation, Signal payload
construction, CSV parsing and dispatch (BOM handling, comma repair, blank
rows, skips, dry run, `--json` output), and CLI argument validation.
## Requirements
- Python 3.10 or newer
- `pytest` (the only test dependency — sendmsg itself is stdlib-only)
```bash
pip install pytest
# or on a system where pip refuses to touch system packages (Homebrew, Debian):
pip install pytest --break-system-packages
# or keep it isolated:
python3 -m venv .venv && source .venv/bin/activate && pip install pytest
```
## Running the tests
Run from the repository root (the tests locate the `sendmsg` script
relative to their own path, so the working directory doesn't actually
matter — but the root is the natural place):
```bash
python3 -m pytest tests/
```
Expected output looks like:
```
..................................................................... [100%]
69 passed in 0.14s
```
### Useful variations
```bash
python3 -m pytest tests/ -v # one line per test, with names
python3 -m pytest tests/ -q # terse summary only
python3 -m pytest tests/ -k csv # only tests matching "csv"
python3 -m pytest tests/ -k "bom or comma" # match multiple keywords
python3 -m pytest tests/test_sendmsg.py::TestPhoneNumbers # one test class
python3 -m pytest tests/test_sendmsg.py::TestCsv::test_quoted_commas_in_message # one test
python3 -m pytest tests/ -x # stop at the first failure
python3 -m pytest tests/ --lf # re-run only what failed last time
```
## What the tests do NOT need
- **No running signal-cli-rest-api container.** All network calls
(`signal_rest_post`, `signal_rest_get`) are replaced with fakes; the
suite asserts on the payloads sendmsg *would* send.
- **No macOS / no `imsg`.** SMS sends are stubbed the same way, so the
suite runs identically on Linux and macOS.
- **No real config.** The suite pins `SIGNAL_REST_URL` and
`SIGNAL_ACCOUNT` environment variables *before* importing the script,
so your real `~/.sendmsg.conf` and shell environment can't leak into
(or be touched by) test runs. Nothing is ever actually sent.
## How the script gets imported
`sendmsg` has no `.py` extension, so the suite loads it with
`importlib`'s `SourceFileLoader` (see the top of `tests/test_sendmsg.py`).
The loaded module is a normal Python module — tests call its functions
(`normalize_account`, `send_one_signal`, `cmd_csv` via `main()`, ...)
directly and monkeypatch its globals.
Two consequences worth knowing:
1. Module-level code (config loading) runs at import time — that's why
the env vars are pinned first.
2. If you rename or move the `sendmsg` script, update `SCRIPT_PATH` at
the top of the test file.
## Continuous integration
`.github/workflows/ci.yml` runs on every push and pull request:
a compile check (`python -m py_compile sendmsg`) followed by the full
suite on Python 3.10, 3.11, 3.12, and 3.13. A green run on your branch
means the same command that CI uses passed:
```bash
python -m py_compile sendmsg && python3 -m pytest tests/ -v
```
## Adding tests
- Pure helpers (parsing, normalization, validation) get direct
parametrized tests — see `TestPhoneNumbers` for the pattern.
- Anything that would send goes through the stubbing helpers:
`run_csv()` for CSV behavior (returns exit code, stdout, stderr, and
the captured Signal/SMS calls) and the `captured_posts` fixture for
payload-level assertions.
- When fixing a bug, add a test that fails on the old behavior first —
most tests in the suite carry a comment naming the bug they pin down.

906
sendmsg

File diff suppressed because it is too large Load Diff

444
tests/test_sendmsg.py Executable file
View File

@ -0,0 +1,444 @@
"""Unit tests for the sendmsg CLI.
The script has no .py extension, so it's loaded via SourceFileLoader.
Environment variables are pinned before import so a developer's real
~/.sendmsg.conf or env can't leak into test results.
"""
import importlib.util
import io
import json
import os
import sys
from importlib.machinery import SourceFileLoader
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent
SCRIPT_PATH = REPO_ROOT / "sendmsg"
# Pin config to known values BEFORE the module-level load_config() runs.
os.environ["SIGNAL_REST_URL"] = "http://localhost:8080"
os.environ["SIGNAL_ACCOUNT"] = "+15550001111"
loader = SourceFileLoader("sendmsg_module", str(SCRIPT_PATH))
spec = importlib.util.spec_from_loader("sendmsg_module", loader)
sendmsg = importlib.util.module_from_spec(spec)
loader.exec_module(sendmsg)
# ---------------------------------------------------------------------------
# Phone-number handling
# ---------------------------------------------------------------------------
class TestPhoneNumbers:
@pytest.mark.parametrize("raw,expected", [
("+18005551212", "+18005551212"),
("18005551212", "+18005551212"),
("+1 (800) 555-1212", "+18005551212"), # formatted (bug fix)
("555-867-5309", "+5558675309"),
("+1.800.555.1212", "+18005551212"),
(" +18005551212 ", "+18005551212"),
("group.ZzBHd3NZ", "group.ZzBHd3NZ"), # groups pass through
("", ""),
(None, None),
])
def test_normalize_account(self, raw, expected):
assert sendmsg.normalize_account(raw) == expected
@pytest.mark.parametrize("value,expected", [
("+18005551212", True),
("18005551212", True),
("+1 (800) 555-1212", True), # bug fix: formatted numbers
("555-867-5309", True),
("+1.800.555.1212", True),
("group.ZzBHd3NZ", False),
("ZzBHd3NZOWlrY2xrpB==", False), # raw base64 group key
("+", False),
("", False),
(None, False),
("()- .", False), # separators only is not a number
])
def test_looks_like_phone_number(self, value, expected):
assert sendmsg.looks_like_phone_number(value) is expected
@pytest.mark.parametrize("value,expected", [
("group.ZzBHd3NZ", True),
("ZzBHd3NZOWlrY2xrpB==", True), # raw base64 key is a group
("+18005551212", False),
("18005551212", False),
("+1 (800) 555-1212", False), # bug fix: NOT a group
("555-867-5309", False), # bug fix: NOT a group
("", False),
(None, False),
])
def test_is_group_id(self, value, expected):
assert sendmsg.is_group_id(value) is expected
def test_detectors_agree(self):
"""A value must never be both a phone number and a group ID."""
for v in ["+18005551212", "555-867-5309", "group.abc", "Zz09==", "+1 (800) 555-1212"]:
assert not (sendmsg.looks_like_phone_number(v) and sendmsg.is_group_id(v))
def test_normalize_recipient(self):
assert sendmsg.normalize_recipient("800-555-1212") == "+8005551212"
assert sendmsg.normalize_recipient("group.abc") == "group.abc"
# ---------------------------------------------------------------------------
# Path expansion / attachment validation
# ---------------------------------------------------------------------------
class TestPaths:
def test_expand_path_tilde(self, monkeypatch):
monkeypatch.setenv("HOME", "/home/tester")
assert sendmsg.expand_path("~/pic.jpg") == "/home/tester/pic.jpg"
def test_expand_path_env_var(self, monkeypatch):
monkeypatch.setenv("PICDIR", "/data/pics")
assert sendmsg.expand_path("$PICDIR/a.png") == "/data/pics/a.png"
def test_expand_path_empty(self):
assert sendmsg.expand_path("") == ""
assert sendmsg.expand_path(None) is None
def test_validate_attachment_missing(self, capsys):
assert sendmsg.validate_attachment("/nope/missing.bin") is None
assert "not found" in capsys.readouterr().err
def test_validate_attachment_ok(self, tmp_path):
f = tmp_path / "a.txt"
f.write_text("hi")
assert sendmsg.validate_attachment(str(f)) == str(f)
def test_validate_attachment_too_large(self, tmp_path, monkeypatch, capsys):
f = tmp_path / "big.bin"
f.write_bytes(b"x" * 10)
monkeypatch.setattr(sendmsg, "MAX_ATTACHMENT_BYTES", 5)
assert sendmsg.validate_attachment(str(f)) is None
assert "too large" in capsys.readouterr().err
def test_validate_voice_format_warning(self, tmp_path, capsys):
f = tmp_path / "clip.txt"
f.write_text("not audio")
# Unrecognized format warns but still returns the path.
assert sendmsg.validate_attachment(str(f), as_voice=True) == str(f)
assert "not a recognized voice/audio format" in capsys.readouterr().err
# ---------------------------------------------------------------------------
# Accounts response shape tolerance
# ---------------------------------------------------------------------------
class TestExtractAccounts:
def test_none(self):
assert sendmsg._extract_accounts(None) == []
def test_bare_list(self):
assert sendmsg._extract_accounts(["+1", "+2"]) == ["+1", "+2"]
def test_accounts_dict(self):
assert sendmsg._extract_accounts({"accounts": ["+1"]}) == ["+1"]
def test_number_dict(self):
assert sendmsg._extract_accounts({"number": "+1"}) == ["+1"]
def test_unknown_shape(self):
assert sendmsg._extract_accounts({"weird": 1}) == []
assert sendmsg._extract_accounts("string") == []
# ---------------------------------------------------------------------------
# Signal payload construction (voice fix)
# ---------------------------------------------------------------------------
class TestSignalPayloads:
@pytest.fixture
def captured_posts(self, monkeypatch):
calls = []
def fake_post(endpoint, payload=None, files=None):
calls.append({"endpoint": endpoint, "payload": dict(payload or {}), "files": files})
return {}
monkeypatch.setattr(sendmsg, "signal_rest_post", fake_post)
return calls
def test_voice_flag_not_sent(self, captured_posts, tmp_path):
"""/v2/send has no 'voice' field upstream; we must not send one."""
clip = tmp_path / "note.m4a"
clip.write_bytes(b"\x00\x01")
ok = sendmsg.send_one_signal("+15550001111", "+15550002222",
message=None, voice=str(clip))
assert ok is True
assert len(captured_posts) == 1
call = captured_posts[0]
assert call["endpoint"] == "/v2/send"
assert "voice" not in call["payload"]
assert call["files"] == str(clip) or call["files"] == [str(clip)]
def test_voice_carries_text_when_no_attachments(self, captured_posts, tmp_path):
clip = tmp_path / "note.m4a"
clip.write_bytes(b"\x00")
sendmsg.send_one_signal("+15550001111", "+15550002222",
message="hi", voice=str(clip))
assert len(captured_posts) == 1
assert captured_posts[0]["payload"].get("message") == "hi"
def test_voice_plus_attachment_two_sends_text_once(self, captured_posts, tmp_path):
clip = tmp_path / "note.m4a"
clip.write_bytes(b"\x00")
doc = tmp_path / "doc.pdf"
doc.write_bytes(b"%PDF")
sendmsg.send_one_signal("+15550001111", "+15550002222",
message="hi", voice=str(clip), attach=[str(doc)])
assert len(captured_posts) == 2
voice_call, attach_call = captured_posts
assert "message" not in voice_call["payload"]
assert attach_call["payload"].get("message") == "hi"
def test_formatted_number_routed_direct_not_group(self, captured_posts):
sendmsg.send_one_signal("+15550001111", "+1 (555) 000-2222", message="hi")
assert captured_posts[0]["payload"]["recipients"] == ["+15550002222"]
def test_empty_send_skipped(self, captured_posts, capsys):
ok = sendmsg.send_one_signal("+15550001111", "+15550002222", message=None)
assert ok is False
assert captured_posts == []
assert "skipped" in capsys.readouterr().err
# ---------------------------------------------------------------------------
# CSV behavior (run through cmd_csv with sends stubbed out)
# ---------------------------------------------------------------------------
def run_csv(monkeypatch, tmp_path, csv_bytes, argv_extra=(), signal_result=True, sms_result=True):
"""Write csv_bytes to a file, run cmd_csv with stubbed senders.
Returns (exit_code, stdout, stderr, signal_calls, sms_calls).
"""
csv_file = tmp_path / "batch.csv"
csv_file.write_bytes(csv_bytes)
signal_calls, sms_calls = [], []
monkeypatch.setattr(sendmsg, "send_one_signal",
lambda **kw: signal_calls.append(kw) or signal_result)
monkeypatch.setattr(sendmsg, "send_one_sms",
lambda **kw: sms_calls.append(kw) or sms_result)
monkeypatch.setattr(sendmsg.time, "sleep", lambda s: None)
argv = ["sendmsg", "--csv", str(csv_file), *argv_extra]
monkeypatch.setattr(sys, "argv", argv)
out, err = io.StringIO(), io.StringIO()
monkeypatch.setattr(sys, "stdout", out)
monkeypatch.setattr(sys, "stderr", err)
code = 0
try:
sendmsg.main()
except SystemExit as e:
code = e.code or 0
return code, out.getvalue(), err.getvalue(), signal_calls, sms_calls
HEADER = b"method,recipient,name,message,account,service,file,voice,delay\n"
class TestCsv:
def test_bom_does_not_misroute_sms_rows(self, monkeypatch, tmp_path):
"""A UTF-8 BOM previously blanked the method column, silently
sending explicit 'sms' rows via Signal."""
data = b"\xef\xbb\xbf" + HEADER + b"sms,+15550003333,Bob,Hi,,imessage,,,\n"
code, out, err, sig, sms = run_csv(monkeypatch, tmp_path, data)
assert code == 0
assert sig == []
assert len(sms) == 1
assert sms[0]["service"] == "imessage"
def test_missing_recipient_skipped(self, monkeypatch, tmp_path):
data = HEADER + b"signal,,NoOne,Hello,,,,,\n"
code, out, err, sig, sms = run_csv(monkeypatch, tmp_path, data)
assert sig == [] and sms == []
assert "missing recipient" in err
assert code == 0 # a skip is not a failure
def test_json_stdout_is_pure_json(self, monkeypatch, tmp_path):
data = HEADER + (b"signal,+15550003333,A,Hello,,,,,\n"
b"signal,+15550004444,B,World,,,,,\n")
code, out, err, sig, sms = run_csv(monkeypatch, tmp_path, data,
argv_extra=["--json"])
summary = json.loads(out) # would raise if progress lines leaked in
assert summary["success"] == 2
assert summary["failed"] == 0
assert "[1/2]" in err # progress went to stderr
def test_dry_run_counts_and_flags_missing_files(self, monkeypatch, tmp_path):
data = HEADER + b"signal,+15550003333,A,Hello,,,/definitely/missing.jpg,,\n"
code, out, err, sig, sms = run_csv(monkeypatch, tmp_path, data,
argv_extra=["--dry-run", "--json"])
assert sig == [] and sms == []
summary = json.loads(out)
assert summary["would_send"] == 1
assert any("attachment not found" in n for n in summary["notes"])
def test_blank_rows_accounted(self, monkeypatch, tmp_path):
data = HEADER + (b"signal,+15550003333,A,Hello,,,,,\n"
b",,,,,,,,\n")
code, out, err, sig, sms = run_csv(monkeypatch, tmp_path, data,
argv_extra=["--json"])
summary = json.loads(out)
assert summary["total"] == 2
assert summary["blank"] == 1
assert summary["blank"] + summary["success"] + summary["skipped"] + summary["failed"] == summary["total"]
def test_failed_send_sets_exit_code(self, monkeypatch, tmp_path):
data = HEADER + b"signal,+15550003333,A,Hello,,,,,\n"
code, *_ = run_csv(monkeypatch, tmp_path, data, signal_result=False)
assert code == 1
def test_sms_row_requires_service(self, monkeypatch, tmp_path):
data = HEADER + b"sms,+15550003333,A,Hello,,,,,\n"
code, out, err, sig, sms = run_csv(monkeypatch, tmp_path, data)
assert sms == []
assert "requires service" in err
def test_voice_on_sms_row_skipped(self, monkeypatch, tmp_path):
data = HEADER + b"sms,+15550003333,A,Hello,,imessage,,~/note.m4a,\n"
code, out, err, sig, sms = run_csv(monkeypatch, tmp_path, data)
assert sms == []
assert "voice" in err
def test_group_recipient_routed_as_group(self, monkeypatch, tmp_path):
data = HEADER + b"signal,group.ZzBHd3NZ,Team,Hi all,,,,,\n"
code, out, err, sig, sms = run_csv(monkeypatch, tmp_path, data)
assert sig[0]["group_id"] == "group.ZzBHd3NZ"
def test_formatted_phone_row_not_group(self, monkeypatch, tmp_path):
data = HEADER + b'signal,555-867-5309,Jenny,Hi,,,,,\n'
code, out, err, sig, sms = run_csv(monkeypatch, tmp_path, data)
assert sig[0]["group_id"] is None
def test_bad_delay_rejected_cleanly(self, monkeypatch, tmp_path):
"""--delay abc previously crashed with a ValueError traceback."""
data = HEADER + b"signal,+15550003333,A,Hello,,,,,\n"
code, out, err, sig, sms = run_csv(monkeypatch, tmp_path, data,
argv_extra=["--delay", "abc"])
assert code == 2 # argparse usage error
assert "invalid float value" in err
assert sig == []
# -- commas in the message field ------------------------------------
def test_quoted_commas_in_message(self, monkeypatch, tmp_path):
"""Standard CSV quoting must pass commas through untouched."""
data = HEADER + b'signal,+15550003333,Al,"Hi, there, friend",,,,,2\n'
code, out, err, sig, sms = run_csv(monkeypatch, tmp_path, data)
assert code == 0
assert sig[0]["message"] == "Hi, there, friend"
assert "unquoted commas" not in err # no repair needed
def test_unquoted_commas_merged_into_message(self, monkeypatch, tmp_path):
"""Unquoted commas previously shifted every column after 'message'
(text leaked into 'account', etc.); they are now merged back."""
data = HEADER + b"signal,+15550003333,Al,Hey, how are you, friend,,,,,\n"
code, out, err, sig, sms = run_csv(monkeypatch, tmp_path, data)
assert code == 0
assert sig[0]["message"] == "Hey, how are you, friend"
assert sig[0]["account"] == "+15550001111" # default, not leaked text
assert "unquoted commas" in err
def test_unquoted_commas_preserve_trailing_columns(self, monkeypatch, tmp_path):
"""Columns after the message (service, voice, delay...) must still
land in the right place after the merge."""
data = HEADER + b"sms,+15550003333,Al,Hi, there,,imessage,,,2\n"
code, out, err, sig, sms = run_csv(monkeypatch, tmp_path, data)
assert code == 0
assert sms[0]["message"] == "Hi, there"
assert sms[0]["service"] == "imessage"
def test_unquoted_comma_repair_noted_in_json(self, monkeypatch, tmp_path):
data = HEADER + b"signal,+15550003333,Al,One, two,,,,,\n"
code, out, err, sig, sms = run_csv(monkeypatch, tmp_path, data,
argv_extra=["--json"])
summary = json.loads(out)
assert summary["success"] == 1
assert any("unquoted commas" in n for n in summary["notes"])
def test_short_rows_tolerated(self, monkeypatch, tmp_path):
"""Rows with fewer fields than the header must not crash."""
data = HEADER + b"signal,+15550003333,Al,Hello\n"
code, out, err, sig, sms = run_csv(monkeypatch, tmp_path, data)
assert code == 0
assert sig[0]["message"] == "Hello"
# ---------------------------------------------------------------------------
# CLI argument validation
# ---------------------------------------------------------------------------
class TestCli:
def run_main(self, monkeypatch, argv):
monkeypatch.setattr(sys, "argv", ["sendmsg", *argv])
out, err = io.StringIO(), io.StringIO()
monkeypatch.setattr(sys, "stdout", out)
monkeypatch.setattr(sys, "stderr", err)
code = 0
try:
sendmsg.main()
except SystemExit as e:
code = e.code or 0
return code, out.getvalue(), err.getvalue()
def test_no_action_errors(self, monkeypatch):
code, out, err = self.run_main(monkeypatch, [])
assert code == 1
assert "--list-groups" in err # new command advertised
def test_multiple_actions_rejected(self, monkeypatch):
code, out, err = self.run_main(monkeypatch, ["--list-signal", "--link-signal"])
assert code == 1
assert "only one action" in err
def test_sms_fans_out_to_all_recipients(self, monkeypatch):
calls = []
monkeypatch.setattr(sendmsg, "send_one_sms",
lambda **kw: calls.append(kw) or True)
code, out, err = self.run_main(
monkeypatch,
["--sms", "--to", "+15550001111", "+15550002222",
"--text", "hi", "--service", "sms"],
)
assert code == 0
assert [c["to"] for c in calls] == ["+15550001111", "+15550002222"]
def test_list_groups_refuses_placeholder_account(self, monkeypatch):
monkeypatch.setattr(sendmsg, "SIGNAL_DEFAULT_ACCOUNT", "+1234567890")
code, out, err = self.run_main(monkeypatch, ["--list-groups"])
assert code == 1
assert "placeholder" in err
def test_list_groups_output(self, monkeypatch):
monkeypatch.setattr(sendmsg, "signal_rest_get", lambda ep: [
{"name": "Ops", "id": "group.QWJj", "members": ["+1", "+2"], "blocked": False},
{"name": None, "internal_id": "Zz09"},
])
code, out, err = self.run_main(
monkeypatch, ["--list-groups", "--account", "+15550001111"])
assert code == 0
assert "Ops" in out
assert "group.QWJj" in out
assert "2 member(s)" in out
assert "(unnamed)" in out
def test_list_groups_url_encodes_account(self, monkeypatch):
seen = {}
def fake_get(endpoint):
seen["endpoint"] = endpoint
return []
monkeypatch.setattr(sendmsg, "signal_rest_get", fake_get)
self.run_main(monkeypatch, ["--list-groups", "--account", "+15550001111"])
assert seen["endpoint"] == "/v1/groups/%2B15550001111"