mirror of
https://github.com/docwho76/sendmsg.git
synced 2026-08-31 01:41:10 -07:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5a4820f1c | ||
|
|
7275a973e4 | ||
|
|
e2b44811fd | ||
|
|
be4225b8c4 | ||
|
|
6db49e60e0 | ||
|
|
4931658f9a | ||
|
|
bb57b78bed | ||
|
|
3454908def | ||
|
|
7b43087a0b | ||
|
|
83d70b6b23 | ||
|
|
9a598f6aa4 | ||
|
|
ec6312e021 | ||
|
|
73c143d38f |
86
CHANGELOG.md
Normal file
86
CHANGELOG.md
Normal 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.10–3.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
274
README.md
@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
`sendmsg` is a command-line tool for sending messages via the [Signal REST API](https://github.com/bbernhard/signal-cli-rest-api) or locally via macOS [Messages](https://apps.apple.com/app/messages/id1092291483) (iMessage/SMS) through the [`imsg`] CLI.
|
`sendmsg` is a command-line tool for sending messages via the [Signal REST API](https://github.com/bbernhard/signal-cli-rest-api) or locally via macOS [Messages](https://apps.apple.com/app/messages/id1092291483) (iMessage/SMS) through the [`imsg`] CLI.
|
||||||
|
|
||||||
It supports individual direct messages, Signal group broadcasts, file attachments, and bulk sending from CSV files — all with a single, consistent interface.
|
It supports individual direct messages, Signal group broadcasts, file attachments, Signal voice messages, and bulk sending from CSV files — all with a single, consistent interface.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -15,6 +15,7 @@ It supports individual direct messages, Signal group broadcasts, file attachment
|
|||||||
- [Installation](#installation)
|
- [Installation](#installation)
|
||||||
- [Usage](#usage)
|
- [Usage](#usage)
|
||||||
* [Signal Messages](#signal-messages)
|
* [Signal Messages](#signal-messages)
|
||||||
|
* [Voice Messages](#voice-messages)
|
||||||
* [SMS / iMessage](#sms--imessage)
|
* [SMS / iMessage](#sms--imessage)
|
||||||
* [Bulk Send from CSV](#bulk-send-from-csv)
|
* [Bulk Send from CSV](#bulk-send-from-csv)
|
||||||
* [Management Commands](#management-commands)
|
* [Management Commands](#management-commands)
|
||||||
@ -23,6 +24,7 @@ It supports individual direct messages, Signal group broadcasts, file attachment
|
|||||||
- [Examples](#examples)
|
- [Examples](#examples)
|
||||||
- [Error Handling](#error-handling)
|
- [Error Handling](#error-handling)
|
||||||
- [Summary Output](#summary-output)
|
- [Summary Output](#summary-output)
|
||||||
|
- [Changelog](#changelog)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -53,7 +55,7 @@ It supports individual direct messages, Signal group broadcasts, file attachment
|
|||||||
└─────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
**Signal path:** `sendmsg` → JSON HTTP POST → `signal-cli-rest-api` → Docker container → Signal network. Attachments are base64-encoded inline in the JSON request body (the API does not accept multipart uploads).
|
**Signal path:** `sendmsg` → JSON HTTP POST → `signal-cli-rest-api` → Docker container → Signal network. Attachments are base64-encoded inline in the JSON request body (the API does not accept multipart uploads). Voice messages are sent the same way, 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
98
TESTING.md
Normal 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.
|
||||||
444
tests/test_sendmsg.py
Executable file
444
tests/test_sendmsg.py
Executable 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"
|
||||||
Loading…
Reference in New Issue
Block a user