mirror of
https://github.com/docwho76/sendmsg.git
synced 2026-08-31 09:41:10 -07:00
Compare commits
No commits in common. "main" and "v5.0.0" have entirely different histories.
43
CHANGELOG.md
43
CHANGELOG.md
@ -2,49 +2,6 @@
|
|||||||
|
|
||||||
All notable changes to `sendmsg` are documented here.
|
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
|
## 5.0.0
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
78
README.md
78
README.md
@ -55,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). 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.
|
**Signal path:** `sendmsg` → JSON HTTP POST → `signal-cli-rest-api` → Docker container → Signal network. Attachments are base64-encoded inline in the JSON request body (the API does not accept multipart uploads). Voice messages are sent the same way with the API's `voice` flag set so they render as playable voice notes.
|
||||||
|
|
||||||
**SMS/iMessage path:** `sendmsg` → subprocess call → `imsg` CLI → macOS Messages framework → carrier/Apple.
|
**SMS/iMessage path:** `sendmsg` → subprocess call → `imsg` CLI → macOS Messages framework → carrier/Apple.
|
||||||
|
|
||||||
@ -107,52 +107,15 @@ 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
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -177,7 +140,6 @@ 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
|
||||||
```
|
```
|
||||||
@ -211,10 +173,8 @@ sendmsg --signal --to +18885551212 --attach ~/report.pdf
|
|||||||
|
|
||||||
### Voice Messages
|
### Voice Messages
|
||||||
|
|
||||||
Send an audio file as a Signal **voice message** with `--voice`. The audio is
|
Send an audio file as a Signal **voice note** (a playable voice message,
|
||||||
delivered as an audio attachment in its own message (the Signal REST API has
|
not a file attachment) with `--voice`:
|
||||||
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
|
# Send a voice message
|
||||||
@ -270,10 +230,6 @@ sendmsg --csv messages.csv --yes # Skip the large-batch confirmation
|
|||||||
# 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"
|
||||||
@ -298,12 +254,11 @@ Create a CSV file with the following columns:
|
|||||||
| `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` | Varies | SMS service: `imessage` or `sms`. **Required** on `sms` rows; ignored for `signal`. |
|
| `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. |
|
||||||
| `voice` | No | Path to an audio file to send as a Signal **voice message**. Signal only. |
|
| `voice` | No | Path to an audio file to send as a Signal **voice note**. Signal only. |
|
||||||
| `delay` | No | Seconds to wait after this row is sent (overrides the global `--delay`). |
|
| `delay` | No | Seconds to wait after this row is sent (overrides the global `--delay`). |
|
||||||
|
|
||||||
**Notes**
|
**Notes**
|
||||||
|
|
||||||
- **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"`).
|
|
||||||
- `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`.
|
- `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.
|
- 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.
|
- A voice note is sent as its own message; if a row has both `voice` and `file`, they are delivered as separate messages.
|
||||||
@ -374,7 +329,7 @@ $ sendmsg --show-config
|
|||||||
==================================================
|
==================================================
|
||||||
⚙️ sendmsg configuration
|
⚙️ sendmsg configuration
|
||||||
==================================================
|
==================================================
|
||||||
Version: 5.1.0
|
Version: 5.0.0
|
||||||
|
|
||||||
Config file: /Users/you/.sendmsg.conf (found)
|
Config file: /Users/you/.sendmsg.conf (found)
|
||||||
|
|
||||||
@ -487,29 +442,12 @@ After processing all rows, a summary is printed:
|
|||||||
==================================================
|
==================================================
|
||||||
```
|
```
|
||||||
|
|
||||||
> 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
|
||||||
|
|
||||||
See [CHANGELOG.md](CHANGELOG.md) for the full version history.
|
See [CHANGELOG.md](CHANGELOG.md) for the full version history.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*For support or issues, see <https://xkcd.com/627/>*
|
*For support or issues, see <https://xkcd.com/627/>*
|
||||||
|
|||||||
98
TESTING.md
98
TESTING.md
@ -1,98 +0,0 @@
|
|||||||
# 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.
|
|
||||||
539
sendmsg
539
sendmsg
@ -9,20 +9,19 @@ Usage:
|
|||||||
sendmsg --sms --to +18005551212 --text "Hey" --service imessage --file ~/photo.png
|
sendmsg --sms --to +18005551212 --text "Hey" --service imessage --file ~/photo.png
|
||||||
sendmsg --signal --to +18005551212 --text "Multi" --text "more text"
|
sendmsg --signal --to +18005551212 --text "Multi" --text "more text"
|
||||||
sendmsg --signal --to +18005551212 --attach ~/doc.pdf ~/pic.jpg
|
sendmsg --signal --to +18005551212 --attach ~/doc.pdf ~/pic.jpg
|
||||||
sendmsg --signal --to +18005551212 --voice ~/note.m4a # Send an audio/voice message
|
sendmsg --signal --to +18005551212 --voice ~/note.m4a # Send a voice message
|
||||||
sendmsg --csv messages.csv # Bulk send from CSV file
|
sendmsg --csv messages.csv # Bulk send from CSV file
|
||||||
sendmsg --csv messages.csv --delay 2 # Wait N seconds between sends
|
sendmsg --csv messages.csv --delay 2 # Wait N seconds between sends
|
||||||
sendmsg --csv messages.csv --dry-run # Preview without sending
|
sendmsg --csv messages.csv --dry-run # Preview without sending
|
||||||
sendmsg --list-signal # Show linked Signal accounts
|
sendmsg --list-signal # Show linked Signal accounts
|
||||||
sendmsg --list-groups # Show Signal groups and their IDs
|
|
||||||
sendmsg --link-signal # Link a new device to your existing Signal account
|
sendmsg --link-signal # Link a new device to your existing Signal account
|
||||||
|
|
||||||
CSV format (messages.csv):
|
CSV format (messages.csv):
|
||||||
method,recipient,name,message,account,service,file,voice,delay
|
method,recipient,name,message,account,service,file,delay
|
||||||
signal,+1888222333,Alice,Hello via Signal,+1888222333,,,,
|
signal,+1888222333,Alice,Hello via Signal,+1888222333,,,
|
||||||
signal,group.ZzBHd3NZO...,Group alert,Hi team,,,,,
|
signal,group.ZzBHd3NZO...,Group alert,Hi team,,,,
|
||||||
sms,+1234567890,Bob,SMS test,,imessage,~/pic.jpg,,
|
sms,+1234567890,Bob,SMS test,,imessage,~/pic.jpg,
|
||||||
signal,+1888222333,Carol,With delay,,,,,3
|
signal,+1888222333,Carol,With delay,,,,3
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@ -40,7 +39,7 @@ import urllib.error
|
|||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
VERSION = "5.2.0"
|
VERSION = "5.0.0"
|
||||||
|
|
||||||
CONFIG_PATHS = [
|
CONFIG_PATHS = [
|
||||||
os.path.expanduser("~/.sendmsg.conf"),
|
os.path.expanduser("~/.sendmsg.conf"),
|
||||||
@ -109,18 +108,6 @@ CFG = load_config()
|
|||||||
SIGNAL_REST_URL = CFG["signal_rest_url"]
|
SIGNAL_REST_URL = CFG["signal_rest_url"]
|
||||||
SIGNAL_DEFAULT_ACCOUNT = CFG["signal_default_account"]
|
SIGNAL_DEFAULT_ACCOUNT = CFG["signal_default_account"]
|
||||||
|
|
||||||
# The built-in default account is a placeholder; sending from it will fail at
|
|
||||||
# the Signal server. Track whether the resolved account is still the default
|
|
||||||
# so we can warn before attempting a real send.
|
|
||||||
PLACEHOLDER_ACCOUNT = "+1234567890"
|
|
||||||
|
|
||||||
if not (SIGNAL_REST_URL.startswith("http://") or SIGNAL_REST_URL.startswith("https://")):
|
|
||||||
print(
|
|
||||||
f"⚠️ Warning: signal_rest_url '{SIGNAL_REST_URL}' does not look like a "
|
|
||||||
f"valid URL (expected http:// or https://).",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Helpers — reusable without argparse objects (needed for CSV dispatch)
|
# Helpers — reusable without argparse objects (needed for CSV dispatch)
|
||||||
@ -138,23 +125,9 @@ def expand_path(path):
|
|||||||
return os.path.expanduser(os.path.expandvars(path.strip()))
|
return os.path.expanduser(os.path.expandvars(path.strip()))
|
||||||
|
|
||||||
|
|
||||||
# Characters commonly used to format phone numbers. These must be stripped
|
|
||||||
# before phone-vs-group detection: '+1 (800) 555-1212' is a phone number, but
|
|
||||||
# without stripping it fails the all-digits test and is mis-routed as a
|
|
||||||
# Signal group ID (the same class of bug as issue #3).
|
|
||||||
_PHONE_FORMAT_CHARS = str.maketrans("", "", " \t-.()")
|
|
||||||
|
|
||||||
|
|
||||||
def strip_phone_separators(value):
|
|
||||||
"""Remove spaces, dashes, dots, and parentheses from a phone number."""
|
|
||||||
return value.translate(_PHONE_FORMAT_CHARS)
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_account(account):
|
def normalize_account(account):
|
||||||
"""Ensure an account/phone number carries a leading '+'.
|
"""Ensure an account/phone number carries a leading '+'.
|
||||||
|
|
||||||
Formatting characters (spaces, dashes, dots, parentheses) are stripped
|
|
||||||
so the number is in the E.164-ish form the Signal REST API expects.
|
|
||||||
Group IDs (starting with 'group.') are returned unchanged.
|
Group IDs (starting with 'group.') are returned unchanged.
|
||||||
"""
|
"""
|
||||||
if not account:
|
if not account:
|
||||||
@ -162,7 +135,6 @@ def normalize_account(account):
|
|||||||
account = account.strip()
|
account = account.strip()
|
||||||
if account.startswith("group."):
|
if account.startswith("group."):
|
||||||
return account
|
return account
|
||||||
account = strip_phone_separators(account)
|
|
||||||
if not account.startswith("+"):
|
if not account.startswith("+"):
|
||||||
account = "+" + account
|
account = "+" + account
|
||||||
return account
|
return account
|
||||||
@ -171,20 +143,17 @@ def normalize_account(account):
|
|||||||
def looks_like_phone_number(value):
|
def looks_like_phone_number(value):
|
||||||
"""Return True if value looks like a phone number.
|
"""Return True if value looks like a phone number.
|
||||||
|
|
||||||
Accepts numbers with an explicit '+', bare all-digit strings, and
|
Accepts numbers with an explicit '+', and also bare all-digit strings
|
||||||
numbers containing common formatting characters ('+1 (800) 555-1212',
|
(which a user may type without the leading '+'). This is the inverse
|
||||||
'555-867-5309'). This is the inverse test used by is_group_id, and the
|
test used by is_group_id, and the two must agree on what a bare number
|
||||||
two must agree on what a phone number is so a number is never
|
is so a phone number is never mis-routed as a group.
|
||||||
mis-routed as a group.
|
|
||||||
"""
|
"""
|
||||||
if not value:
|
if not value:
|
||||||
return False
|
return False
|
||||||
value = strip_phone_separators(value.strip())
|
value = value.strip()
|
||||||
if not value:
|
|
||||||
return False
|
|
||||||
if value.startswith("+"):
|
if value.startswith("+"):
|
||||||
return value[1:].isdigit() and len(value) > 1
|
return value[1:].isdigit() and len(value) > 1
|
||||||
# A bare run of digits is a phone number, not a group.
|
# A bare run of digits (optionally long) is a phone number, not a group.
|
||||||
return value.isdigit()
|
return value.isdigit()
|
||||||
|
|
||||||
|
|
||||||
@ -268,14 +237,6 @@ def send_one_signal(account, recipient, message, group_id=None,
|
|||||||
"""
|
"""
|
||||||
account = normalize_account(account)
|
account = normalize_account(account)
|
||||||
|
|
||||||
if account == PLACEHOLDER_ACCOUNT:
|
|
||||||
print(
|
|
||||||
f"⚠️ Warning: sending from the placeholder account {PLACEHOLDER_ACCOUNT}. "
|
|
||||||
f"Set SIGNAL_ACCOUNT or signal_default_account in ~/.sendmsg.conf, "
|
|
||||||
f"or pass --account.",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
|
|
||||||
target = group_id if group_id else normalize_recipient(recipient)
|
target = group_id if group_id else normalize_recipient(recipient)
|
||||||
payload = {"number": account, "recipients": [target]}
|
payload = {"number": account, "recipients": [target]}
|
||||||
|
|
||||||
@ -304,16 +265,12 @@ def send_one_signal(account, recipient, message, group_id=None,
|
|||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# A voice message is sent as its own message, separate from any other
|
# A voice note is sent as its own message (a voice note cannot be mixed
|
||||||
# file attachments. NOTE: signal-cli-rest-api's /v2/send has no
|
# with arbitrary file attachments in a single send).
|
||||||
# voice-note field (a previous release sent "voice": true, which the
|
|
||||||
# API silently ignored), so the audio is delivered as a regular audio
|
|
||||||
# attachment. How it renders (inline player vs. file) is up to the
|
|
||||||
# receiving client.
|
|
||||||
overall_ok = True
|
overall_ok = True
|
||||||
|
|
||||||
if has_voice:
|
if has_voice:
|
||||||
voice_payload = {"number": account, "recipients": [target]}
|
voice_payload = {"number": account, "recipients": [target], "voice": True}
|
||||||
if message and not has_attachment:
|
if message and not has_attachment:
|
||||||
# Attach the text to the voice message if there are no other files.
|
# Attach the text to the voice message if there are no other files.
|
||||||
voice_payload["message"] = message
|
voice_payload["message"] = message
|
||||||
@ -357,40 +314,18 @@ def send_one_sms(to, message, service, file=None):
|
|||||||
print("❌ SMS send failed: 'imsg' CLI not found on PATH.", file=sys.stderr)
|
print("❌ SMS send failed: 'imsg' CLI not found on PATH.", file=sys.stderr)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# imsg can hang if the Messages app is unresponsive; bound each attempt
|
try:
|
||||||
# with a timeout and retry a couple of times on transient failures.
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
max_attempts = 3
|
except OSError as e:
|
||||||
backoff = 2.0
|
print(f"❌ SMS send failed: could not run 'imsg': {e}", file=sys.stderr)
|
||||||
for attempt in range(1, max_attempts + 1):
|
|
||||||
try:
|
|
||||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
if attempt < max_attempts:
|
|
||||||
wait = backoff * attempt
|
|
||||||
print(
|
|
||||||
f"⚠️ SMS send to {to} timed out; retrying in {wait:.0f}s "
|
|
||||||
f"(attempt {attempt}/{max_attempts})...",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
time.sleep(wait)
|
|
||||||
continue
|
|
||||||
print(f"❌ SMS send failed: 'imsg' timed out after {max_attempts} attempts.", file=sys.stderr)
|
|
||||||
return False
|
|
||||||
except OSError as e:
|
|
||||||
print(f"❌ SMS send failed: could not run 'imsg': {e}", file=sys.stderr)
|
|
||||||
return False
|
|
||||||
|
|
||||||
if result.returncode == 0:
|
|
||||||
print(f"✅ SMS/iMessage sent to {to} (service: {service.upper()})")
|
|
||||||
return True
|
|
||||||
|
|
||||||
# A non-zero exit is almost always permanent (bad recipient, Messages
|
|
||||||
# not signed in, etc.); retrying just burns time. Only timeouts —
|
|
||||||
# a hung Messages app — are treated as transient and retried above.
|
|
||||||
print(f"❌ SMS send failed (exit {result.returncode}): {result.stderr.strip()}", file=sys.stderr)
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return False
|
if result.returncode == 0:
|
||||||
|
print(f"✅ SMS/iMessage sent to {to} (service: {service.upper()})")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print(f"❌ SMS send failed (exit {result.returncode}): {result.stderr.strip()}", file=sys.stderr)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@ -483,48 +418,22 @@ def signal_rest_post(endpoint, payload=None, files=None):
|
|||||||
|
|
||||||
|
|
||||||
def signal_rest_get(endpoint):
|
def signal_rest_get(endpoint):
|
||||||
"""GET a JSON response from the signal-cli-rest-api.
|
"""GET a JSON response from the signal-cli-rest-api."""
|
||||||
|
|
||||||
Transient failures (429/5xx, network errors) are retried with backoff,
|
|
||||||
matching the resilience of the POST helper.
|
|
||||||
"""
|
|
||||||
url = f"{SIGNAL_REST_URL.rstrip('/')}{endpoint}"
|
url = f"{SIGNAL_REST_URL.rstrip('/')}{endpoint}"
|
||||||
max_attempts = 3
|
req = urllib.request.Request(url, method="GET")
|
||||||
backoff = 2.0
|
try:
|
||||||
for attempt in range(1, max_attempts + 1):
|
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||||
req = urllib.request.Request(url, method="GET")
|
data = resp.read().decode("utf-8")
|
||||||
try:
|
if data.strip():
|
||||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
return json.loads(data)
|
||||||
data = resp.read().decode("utf-8")
|
|
||||||
if data.strip():
|
|
||||||
return json.loads(data)
|
|
||||||
return None
|
|
||||||
except urllib.error.HTTPError as e:
|
|
||||||
body = e.read().decode("utf-8", errors="replace")
|
|
||||||
if e.code in (429, 500, 502, 503, 504) and attempt < max_attempts:
|
|
||||||
wait = backoff * attempt
|
|
||||||
print(
|
|
||||||
f"⚠️ Signal REST HTTP {e.code}; retrying in {wait:.0f}s "
|
|
||||||
f"(attempt {attempt}/{max_attempts})...",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
time.sleep(wait)
|
|
||||||
continue
|
|
||||||
print(f"❌ Signal REST error: HTTP {e.code} — {body[:500]}", file=sys.stderr)
|
|
||||||
return None
|
return None
|
||||||
except urllib.error.URLError as e:
|
except urllib.error.HTTPError as e:
|
||||||
if attempt < max_attempts:
|
body = e.read().decode("utf-8", errors="replace")
|
||||||
wait = backoff * attempt
|
print(f"❌ Signal REST error: HTTP {e.code} — {body[:500]}", file=sys.stderr)
|
||||||
print(
|
return None
|
||||||
f"⚠️ Cannot reach Signal REST API ({e.reason}); retrying in "
|
except urllib.error.URLError as e:
|
||||||
f"{wait:.0f}s (attempt {attempt}/{max_attempts})...",
|
print(f"❌ Cannot reach Signal REST API at {SIGNAL_REST_URL}: {e.reason}", file=sys.stderr)
|
||||||
file=sys.stderr,
|
return None
|
||||||
)
|
|
||||||
time.sleep(wait)
|
|
||||||
continue
|
|
||||||
print(f"❌ Cannot reach Signal REST API at {SIGNAL_REST_URL}: {e.reason}", file=sys.stderr)
|
|
||||||
return None
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@ -547,39 +456,34 @@ def cmd_signal(args):
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
account = normalize_account(args.account or SIGNAL_DEFAULT_ACCOUNT)
|
account = normalize_account(args.account or SIGNAL_DEFAULT_ACCOUNT)
|
||||||
|
|
||||||
|
if args.recipients:
|
||||||
|
recipient = None
|
||||||
|
group_id = args.recipients
|
||||||
|
else:
|
||||||
|
recipient = args.to[0]
|
||||||
|
group_id = None
|
||||||
|
|
||||||
message = "\n".join(args.text) if args.text else None
|
message = "\n".join(args.text) if args.text else None
|
||||||
|
|
||||||
# Build the list of targets. --recipients values are treated as group IDs
|
ok = send_one_signal(
|
||||||
# or raw recipients; --to values are direct phone numbers. Either may
|
account=account,
|
||||||
# contain more than one entry, so a single invocation can fan out.
|
recipient=recipient,
|
||||||
targets = [] # list of (recipient, group_id, label)
|
message=message,
|
||||||
if args.recipients:
|
group_id=group_id,
|
||||||
for r in args.recipients:
|
attach=attach_files,
|
||||||
if is_group_id(r):
|
voice=voice_file,
|
||||||
targets.append((None, r, f"group {r}"))
|
)
|
||||||
else:
|
|
||||||
targets.append((r, None, normalize_recipient(r)))
|
|
||||||
if args.to:
|
|
||||||
for t in args.to:
|
|
||||||
targets.append((t, None, normalize_recipient(t)))
|
|
||||||
|
|
||||||
all_ok = True
|
if ok:
|
||||||
for recipient, group_id, label in targets:
|
if args.recipients:
|
||||||
ok = send_one_signal(
|
print(f"✅ Signal message sent to group {args.recipients}")
|
||||||
account=account,
|
elif args.to:
|
||||||
recipient=recipient,
|
print(f"✅ Signal message sent to {normalize_recipient(args.to[0])}")
|
||||||
message=message,
|
|
||||||
group_id=group_id,
|
|
||||||
attach=attach_files,
|
|
||||||
voice=voice_file,
|
|
||||||
)
|
|
||||||
if ok:
|
|
||||||
print(f"✅ Signal message sent to {label}")
|
|
||||||
else:
|
else:
|
||||||
print(f"❌ Signal message send failed: {label}", file=sys.stderr)
|
print("✅ Signal message sent")
|
||||||
all_ok = False
|
else:
|
||||||
|
print("❌ Signal message send failed.", file=sys.stderr)
|
||||||
if not all_ok:
|
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
@ -611,18 +515,13 @@ def cmd_sms(args):
|
|||||||
else:
|
else:
|
||||||
print(f"⚠️ Warning: SMS supports one attachment; ignoring extra file: {ef}", file=sys.stderr)
|
print(f"⚠️ Warning: SMS supports one attachment; ignoring extra file: {ef}", file=sys.stderr)
|
||||||
|
|
||||||
# Fan out to every --to recipient, matching the Signal path. Previously
|
ok = send_one_sms(
|
||||||
# only args.to[0] was sent and extra recipients were silently dropped.
|
to=args.to[0],
|
||||||
all_ok = True
|
message=msg_text,
|
||||||
for to in args.to:
|
service=args.service,
|
||||||
ok = send_one_sms(
|
file=chosen_file,
|
||||||
to=normalize_account(to),
|
)
|
||||||
message=msg_text,
|
if not ok:
|
||||||
service=args.service,
|
|
||||||
file=chosen_file,
|
|
||||||
)
|
|
||||||
all_ok = all_ok and ok
|
|
||||||
if not all_ok:
|
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
@ -639,83 +538,28 @@ def cmd_csv(args):
|
|||||||
|
|
||||||
# Global delay from --delay applies between every send unless a row
|
# Global delay from --delay applies between every send unless a row
|
||||||
# specifies its own delay (which takes precedence).
|
# specifies its own delay (which takes precedence).
|
||||||
global_delay = args.delay if args.delay else 0.0
|
global_delay = float(args.delay) if args.delay else 0.0
|
||||||
|
|
||||||
# With --json the summary on stdout must be the ONLY stdout output so it
|
with open(filepath, newline="", encoding="utf-8") as csvfile:
|
||||||
# can be piped to a parser; per-row progress then goes to stderr.
|
reader = csv.DictReader(csvfile)
|
||||||
progress = sys.stderr if getattr(args, "json_output", False) else sys.stdout
|
|
||||||
|
|
||||||
# 'utf-8-sig' transparently strips a UTF-8 BOM. Excel and many editors
|
|
||||||
# write one; under plain 'utf-8' it corrupts the first header to
|
|
||||||
# '\ufeffmethod', making every row's method look empty — which then
|
|
||||||
# defaulted to 'signal' and silently misrouted explicit 'sms' rows.
|
|
||||||
# restkey collects overflow fields (see repair below); restval="" fills
|
|
||||||
# missing trailing columns so short rows never produce None values.
|
|
||||||
with open(filepath, newline="", encoding="utf-8-sig") as csvfile:
|
|
||||||
reader = csv.DictReader(csvfile, restkey="_overflow", restval="")
|
|
||||||
fieldnames = reader.fieldnames or []
|
|
||||||
rows = list(reader)
|
rows = list(reader)
|
||||||
|
|
||||||
if not rows:
|
if not rows:
|
||||||
print("❌ Error: CSV file is empty (no data rows).", file=sys.stderr)
|
print("❌ Error: CSV file is empty (no data rows).", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
def repair_overflow(row):
|
# Count the rows that will actually be attempted (non-blank).
|
||||||
"""Merge unquoted-comma overflow back into the message field.
|
actionable = [
|
||||||
|
r for r in rows
|
||||||
Standard CSV quoting ('"Hi, there"') is fully supported and needs no
|
if (r.get("method", "").strip()
|
||||||
repair. But a message containing UNQUOTED commas splits into extra
|
or r.get("message", "").strip()
|
||||||
fields, shifting every column after 'message' (so part of the text
|
or r.get("recipient", "").strip()
|
||||||
lands in 'account', the delay in 'voice', etc.) and corrupting the
|
or r.get("file", "").strip()
|
||||||
row silently. When a row has more fields than the header, the only
|
or r.get("voice", "").strip())
|
||||||
sane interpretation is that the extras belong to the free-text
|
]
|
||||||
message column: keep the columns before 'message' from the front,
|
|
||||||
the columns after it from the back, and rejoin everything in the
|
|
||||||
middle with commas.
|
|
||||||
|
|
||||||
Returns (row, n_extra_fields_merged).
|
|
||||||
"""
|
|
||||||
overflow = row.pop("_overflow", None)
|
|
||||||
if not overflow or "message" not in fieldnames:
|
|
||||||
return row, 0
|
|
||||||
idx = fieldnames.index("message")
|
|
||||||
n_after = len(fieldnames) - idx - 1
|
|
||||||
raw = [str(row.get(h) or "") for h in fieldnames] + [str(x) for x in overflow]
|
|
||||||
merged = ",".join(raw[idx:len(raw) - n_after])
|
|
||||||
repaired = dict(zip(fieldnames, raw[:idx] + [merged] + raw[len(raw) - n_after:]))
|
|
||||||
return repaired, len(overflow)
|
|
||||||
|
|
||||||
repair_notes = []
|
|
||||||
for idx0, row in enumerate(rows):
|
|
||||||
repaired, n_extra = repair_overflow(row)
|
|
||||||
if n_extra:
|
|
||||||
rows[idx0] = repaired
|
|
||||||
note = (
|
|
||||||
f"Row {idx0 + 1}: message contains unquoted commas; merged "
|
|
||||||
f"{n_extra} extra field(s) back into the message. Quote the "
|
|
||||||
f'field ("...") to silence this.'
|
|
||||||
)
|
|
||||||
repair_notes.append(note)
|
|
||||||
print(f"⚠️ {note}", file=sys.stderr)
|
|
||||||
|
|
||||||
def row_is_blank(r):
|
|
||||||
"""True if a row has no content in any actionable column.
|
|
||||||
|
|
||||||
Used both for the pre-flight count and the per-row skip so the two
|
|
||||||
can never disagree (previously a row with only a 'file' value was
|
|
||||||
counted as actionable but then silently skipped as blank).
|
|
||||||
"""
|
|
||||||
return not any(
|
|
||||||
(r.get(col) or "").strip()
|
|
||||||
for col in ("method", "recipient", "message", "file", "voice")
|
|
||||||
)
|
|
||||||
|
|
||||||
actionable = [r for r in rows if not row_is_blank(r)]
|
|
||||||
blank = len(rows) - len(actionable)
|
|
||||||
|
|
||||||
if args.dry_run:
|
if args.dry_run:
|
||||||
print(f"🔎 Dry run: {len(actionable)} row(s) would be processed from {filepath}\n",
|
print(f"🔎 Dry run: {len(actionable)} row(s) would be processed from {filepath}\n")
|
||||||
file=progress)
|
|
||||||
elif len(actionable) > CSV_CONFIRM_THRESHOLD and not args.yes:
|
elif len(actionable) > CSV_CONFIRM_THRESHOLD and not args.yes:
|
||||||
try:
|
try:
|
||||||
resp = input(
|
resp = input(
|
||||||
@ -731,18 +575,17 @@ def cmd_csv(args):
|
|||||||
success = 0
|
success = 0
|
||||||
failed = 0
|
failed = 0
|
||||||
skipped = 0
|
skipped = 0
|
||||||
previewed = 0
|
errors = []
|
||||||
errors = list(repair_notes)
|
|
||||||
|
|
||||||
for i, row in enumerate(rows, 1):
|
for i, row in enumerate(rows, 1):
|
||||||
method = (row.get("method") or "").strip().lower()
|
method = row.get("method", "").strip().lower()
|
||||||
message = (row.get("message") or "").strip()
|
message = row.get("message", "").strip()
|
||||||
to = (row.get("recipient") or "").strip()
|
to = row.get("recipient", "").strip()
|
||||||
name = (row.get("name") or "").strip()
|
name = row.get("name", "").strip()
|
||||||
voice_path = (row.get("voice") or "").strip() or None
|
voice_path = row.get("voice", "").strip() or None
|
||||||
|
|
||||||
# Skip blank rows (same test as the pre-flight count above).
|
# Skip blank rows
|
||||||
if row_is_blank(row):
|
if not method and not message and not to and not voice_path:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if not method:
|
if not method:
|
||||||
@ -750,7 +593,7 @@ def cmd_csv(args):
|
|||||||
|
|
||||||
if method not in ("signal", "sms"):
|
if method not in ("signal", "sms"):
|
||||||
print(f"⚠️ Row {i}: unknown method '{method}' — skipping", file=sys.stderr)
|
print(f"⚠️ Row {i}: unknown method '{method}' — skipping", file=sys.stderr)
|
||||||
skipped += 1
|
failed += 1
|
||||||
errors.append(f"Row {i}: unknown method '{method}'")
|
errors.append(f"Row {i}: unknown method '{method}'")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@ -765,28 +608,14 @@ def cmd_csv(args):
|
|||||||
print(f"⚠️ Row {i}: invalid delay '{delay_str}' — ignoring", file=sys.stderr)
|
print(f"⚠️ Row {i}: invalid delay '{delay_str}' — ignoring", file=sys.stderr)
|
||||||
row_delay = None
|
row_delay = None
|
||||||
|
|
||||||
# A recipient is always required. Previously a recipient-less signal
|
|
||||||
# row was passed through and POSTed with an empty recipients entry,
|
|
||||||
# producing a confusing API error (and a dry run reported it as
|
|
||||||
# sendable).
|
|
||||||
if not to:
|
|
||||||
print(f"⚠️ Row {i}: missing recipient — skipping", file=sys.stderr)
|
|
||||||
skipped += 1
|
|
||||||
errors.append(f"Row {i}: missing recipient")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# A message is required UNLESS there is an attachment or a voice note
|
# A message is required UNLESS there is an attachment or a voice note
|
||||||
# (Signal can send attachment-only / voice-only messages). SMS still
|
# (Signal can send attachment-only / voice-only messages). SMS still
|
||||||
# requires text.
|
# requires text.
|
||||||
has_signal_media = method == "signal" and (file_path or voice_path)
|
has_signal_media = method == "signal" and (file_path or voice_path)
|
||||||
if not message and not has_signal_media:
|
if not message and not has_signal_media:
|
||||||
if method == "sms":
|
print(f"⚠️ Row {i}: empty message — skipping", file=sys.stderr)
|
||||||
reason = "empty message (SMS requires text)"
|
|
||||||
else:
|
|
||||||
reason = "empty message (no text, attachment, or voice)"
|
|
||||||
print(f"⚠️ Row {i}: {reason} — skipping", file=sys.stderr)
|
|
||||||
skipped += 1
|
skipped += 1
|
||||||
errors.append(f"Row {i}: {reason}")
|
errors.append(f"Row {i}: empty message")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if method == "sms":
|
if method == "sms":
|
||||||
@ -796,7 +625,7 @@ def cmd_csv(args):
|
|||||||
f"(method 'signal') — skipping",
|
f"(method 'signal') — skipping",
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
skipped += 1
|
failed += 1
|
||||||
errors.append(f"Row {i}: voice not supported for SMS")
|
errors.append(f"Row {i}: voice not supported for SMS")
|
||||||
continue
|
continue
|
||||||
if service not in ("sms", "imessage"):
|
if service not in ("sms", "imessage"):
|
||||||
@ -805,39 +634,28 @@ def cmd_csv(args):
|
|||||||
f"(got '{service or ''}') — skipping",
|
f"(got '{service or ''}') — skipping",
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
skipped += 1
|
failed += 1
|
||||||
errors.append(f"Row {i}: invalid/missing SMS service")
|
errors.append(f"Row {i}: invalid/missing SMS service")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
label = f"{name} ({to})" if name else to or "(group)"
|
label = f"{name} ({to})" if name else to or "(group)"
|
||||||
prefix = "[DRY] " if args.dry_run else ""
|
prefix = "[DRY] " if args.dry_run else ""
|
||||||
print(f"{prefix}[{i}/{total}] {method.upper()} → {label}", file=progress)
|
print(f"{prefix}[{i}/{total}] {method.upper()} → {label}")
|
||||||
|
|
||||||
if args.dry_run:
|
if args.dry_run:
|
||||||
# Report what would happen without sending, and validate the
|
# Report what would happen without sending.
|
||||||
# attachment paths so problems surface BEFORE a real run.
|
|
||||||
extras = []
|
extras = []
|
||||||
if file_path:
|
if file_path:
|
||||||
expanded = expand_path(file_path)
|
extras.append(f"file={file_path}")
|
||||||
if os.path.isfile(expanded):
|
|
||||||
extras.append(f"file={file_path}")
|
|
||||||
else:
|
|
||||||
extras.append(f"file={file_path} [NOT FOUND]")
|
|
||||||
errors.append(f"Row {i}: attachment not found: {expanded}")
|
|
||||||
if voice_path:
|
if voice_path:
|
||||||
expanded = expand_path(voice_path)
|
extras.append(f"voice={voice_path}")
|
||||||
if os.path.isfile(expanded):
|
|
||||||
extras.append(f"voice={voice_path}")
|
|
||||||
else:
|
|
||||||
extras.append(f"voice={voice_path} [NOT FOUND]")
|
|
||||||
errors.append(f"Row {i}: voice file not found: {expanded}")
|
|
||||||
if method == "signal" and is_group_id(to):
|
if method == "signal" and is_group_id(to):
|
||||||
extras.append("group")
|
extras.append("group")
|
||||||
if method == "sms":
|
if method == "sms":
|
||||||
extras.append(f"service={service}")
|
extras.append(f"service={service}")
|
||||||
if extras:
|
if extras:
|
||||||
print(f" ({', '.join(extras)})", file=progress)
|
print(f" ({', '.join(extras)})")
|
||||||
previewed += 1
|
success += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if method == "signal":
|
if method == "signal":
|
||||||
@ -873,39 +691,20 @@ def cmd_csv(args):
|
|||||||
time.sleep(effective_delay)
|
time.sleep(effective_delay)
|
||||||
|
|
||||||
# Summary
|
# Summary
|
||||||
if getattr(args, "json_output", False):
|
print()
|
||||||
summary = {
|
print("=" * 50)
|
||||||
"total": total,
|
print("📊 CSV Send Summary" + (" (DRY RUN)" if args.dry_run else ""))
|
||||||
"blank": blank,
|
print("=" * 50)
|
||||||
"success": success,
|
print(f" Total rows: {total}")
|
||||||
"skipped": skipped,
|
print(f" ✅ Success: {success}")
|
||||||
"failed": failed,
|
print(f" ⏭️ Skipped: {skipped}")
|
||||||
"dry_run": bool(args.dry_run),
|
print(f" ❌ Failed: {failed}")
|
||||||
"notes": errors,
|
if errors:
|
||||||
}
|
|
||||||
if args.dry_run:
|
|
||||||
summary["would_send"] = previewed
|
|
||||||
print(json.dumps(summary, indent=2))
|
|
||||||
else:
|
|
||||||
print()
|
print()
|
||||||
print("=" * 50)
|
print(" Notes:")
|
||||||
print("📊 CSV Send Summary" + (" (DRY RUN)" if args.dry_run else ""))
|
for e in errors:
|
||||||
print("=" * 50)
|
print(f" • {e}")
|
||||||
print(f" Total rows: {total}")
|
print("=" * 50)
|
||||||
if blank:
|
|
||||||
print(f" ⬜ Blank: {blank}")
|
|
||||||
if args.dry_run:
|
|
||||||
print(f" 🔎 Would send: {previewed}")
|
|
||||||
else:
|
|
||||||
print(f" ✅ Success: {success}")
|
|
||||||
print(f" ⏭️ Skipped: {skipped}")
|
|
||||||
print(f" ❌ Failed: {failed}")
|
|
||||||
if errors:
|
|
||||||
print()
|
|
||||||
print(" Notes:")
|
|
||||||
for e in errors:
|
|
||||||
print(f" • {e}")
|
|
||||||
print("=" * 50)
|
|
||||||
|
|
||||||
if failed > 0:
|
if failed > 0:
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
@ -951,24 +750,6 @@ def cmd_show_config(args):
|
|||||||
print("=" * 50)
|
print("=" * 50)
|
||||||
|
|
||||||
|
|
||||||
def _extract_accounts(result):
|
|
||||||
"""Normalize the various shapes /v1/accounts may return into a list of str.
|
|
||||||
|
|
||||||
The REST API has returned a bare list, a {'accounts': [...]} dict, and a
|
|
||||||
single {'number': ...} dict across versions. Returns [] for None/unknown.
|
|
||||||
"""
|
|
||||||
if result is None:
|
|
||||||
return []
|
|
||||||
if isinstance(result, list):
|
|
||||||
return [str(a) for a in result]
|
|
||||||
if isinstance(result, dict):
|
|
||||||
if "accounts" in result and isinstance(result["accounts"], list):
|
|
||||||
return [str(a) for a in result["accounts"]]
|
|
||||||
if "number" in result:
|
|
||||||
return [str(result["number"])]
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_list_signal(args):
|
def cmd_list_signal(args):
|
||||||
"""List linked Signal accounts via the REST API."""
|
"""List linked Signal accounts via the REST API."""
|
||||||
result = signal_rest_get("/v1/accounts")
|
result = signal_rest_get("/v1/accounts")
|
||||||
@ -976,7 +757,14 @@ def cmd_list_signal(args):
|
|||||||
print(f"❌ Failed to list accounts (cannot reach {SIGNAL_REST_URL})", file=sys.stderr)
|
print(f"❌ Failed to list accounts (cannot reach {SIGNAL_REST_URL})", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
accounts = _extract_accounts(result)
|
if isinstance(result, list):
|
||||||
|
accounts = [str(a) for a in result]
|
||||||
|
elif isinstance(result, dict) and "accounts" in result:
|
||||||
|
accounts = result["accounts"]
|
||||||
|
elif isinstance(result, dict) and "number" in result:
|
||||||
|
accounts = [result["number"]]
|
||||||
|
else:
|
||||||
|
accounts = [str(result)]
|
||||||
|
|
||||||
if not accounts:
|
if not accounts:
|
||||||
print("No Signal accounts linked. Use --link-signal to set one up.")
|
print("No Signal accounts linked. Use --link-signal to set one up.")
|
||||||
@ -997,7 +785,9 @@ def cmd_link_signal(args):
|
|||||||
# Snapshot existing accounts so we can detect a genuinely NEW link rather
|
# Snapshot existing accounts so we can detect a genuinely NEW link rather
|
||||||
# than reporting success just because a primary account already exists.
|
# than reporting success just because a primary account already exists.
|
||||||
before = signal_rest_get("/v1/accounts")
|
before = signal_rest_get("/v1/accounts")
|
||||||
before_set = set(_extract_accounts(before))
|
before_set = set()
|
||||||
|
if isinstance(before, list):
|
||||||
|
before_set = {str(a) for a in before}
|
||||||
|
|
||||||
payload = {"name": name}
|
payload = {"name": name}
|
||||||
result = signal_rest_post("/v1/link", payload=payload)
|
result = signal_rest_post("/v1/link", payload=payload)
|
||||||
@ -1021,58 +811,15 @@ def cmd_link_signal(args):
|
|||||||
start = time.time()
|
start = time.time()
|
||||||
while time.time() - start < 120:
|
while time.time() - start < 120:
|
||||||
time.sleep(3)
|
time.sleep(3)
|
||||||
current_set = set(_extract_accounts(signal_rest_get("/v1/accounts")))
|
current = signal_rest_get("/v1/accounts")
|
||||||
new_accounts = current_set - before_set
|
if isinstance(current, list):
|
||||||
if new_accounts:
|
current_set = {str(a) for a in current}
|
||||||
print(f"✅ Device linked! New account(s): {', '.join(sorted(new_accounts))}")
|
new_accounts = current_set - before_set
|
||||||
return
|
if new_accounts:
|
||||||
|
print(f"✅ Device linked! New account(s): {', '.join(sorted(new_accounts))}")
|
||||||
|
return
|
||||||
|
|
||||||
print("⚠️ Linking timed out (no new account detected). Please try again.", file=sys.stderr)
|
print("⚠️ Linking timed out (no new account detected). Please try again.", file=sys.stderr)
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_list_groups(args):
|
|
||||||
"""List the Signal groups the account belongs to, with their IDs.
|
|
||||||
|
|
||||||
Uses GET /v1/groups/{number}. This is the sanctioned way to discover the
|
|
||||||
group IDs that CSV rows and --recipients expect.
|
|
||||||
"""
|
|
||||||
account = normalize_account(args.account or SIGNAL_DEFAULT_ACCOUNT)
|
|
||||||
if account == PLACEHOLDER_ACCOUNT:
|
|
||||||
print(
|
|
||||||
f"❌ Error: no Signal account configured (still the placeholder "
|
|
||||||
f"{PLACEHOLDER_ACCOUNT}). Set SIGNAL_ACCOUNT, set "
|
|
||||||
f"signal_default_account in ~/.sendmsg.conf, or pass --account.",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
endpoint = f"/v1/groups/{urllib.parse.quote(account, safe='')}"
|
|
||||||
result = signal_rest_get(endpoint)
|
|
||||||
if result is None:
|
|
||||||
print(f"❌ Failed to list groups for {account}.", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# Shape-tolerant: expect a list of group dicts, but don't crash on
|
|
||||||
# variations. Known keys across API versions: name, id ('group.<b64>'),
|
|
||||||
# internal_id, members, blocked.
|
|
||||||
groups = result if isinstance(result, list) else result.get("groups", []) if isinstance(result, dict) else []
|
|
||||||
if not groups:
|
|
||||||
print(f"No Signal groups found for {account}.")
|
|
||||||
return
|
|
||||||
|
|
||||||
print(f"Signal groups for {account}:")
|
|
||||||
for g in groups:
|
|
||||||
if not isinstance(g, dict):
|
|
||||||
print(f" • {g}")
|
|
||||||
continue
|
|
||||||
gname = g.get("name") or "(unnamed)"
|
|
||||||
gid = g.get("id") or g.get("internal_id") or "(no id)"
|
|
||||||
members = g.get("members")
|
|
||||||
member_note = f", {len(members)} member(s)" if isinstance(members, list) else ""
|
|
||||||
blocked_note = " [blocked]" if g.get("blocked") else ""
|
|
||||||
print(f" • {gname}{blocked_note}{member_note}")
|
|
||||||
print(f" id: {gid}")
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@ -1094,10 +841,9 @@ def main():
|
|||||||
" %(prog)s --csv messages.csv --delay 2\n"
|
" %(prog)s --csv messages.csv --delay 2\n"
|
||||||
" %(prog)s --csv messages.csv --dry-run\n"
|
" %(prog)s --csv messages.csv --dry-run\n"
|
||||||
" %(prog)s --list-signal\n"
|
" %(prog)s --list-signal\n"
|
||||||
" %(prog)s --list-groups\n"
|
|
||||||
" %(prog)s --link-signal --name 'my-automation'\n"
|
" %(prog)s --link-signal --name 'my-automation'\n"
|
||||||
"\n"
|
"\n"
|
||||||
"CSV columns: method, recipient, name, message, account, service, file, voice, delay\n"
|
"CSV columns: method, recipient, name, message, account, service, file, delay\n"
|
||||||
"The 'name' field is printed in the status output during CSV sends.\n"
|
"The 'name' field is printed in the status output during CSV sends.\n"
|
||||||
"For SMS rows the 'service' column must be 'sms' or 'imessage'.\n"
|
"For SMS rows the 'service' column must be 'sms' or 'imessage'.\n"
|
||||||
f"Signal REST API: {SIGNAL_REST_URL}\n"
|
f"Signal REST API: {SIGNAL_REST_URL}\n"
|
||||||
@ -1127,16 +873,13 @@ def main():
|
|||||||
# Signal-specific
|
# Signal-specific
|
||||||
signal_grp = parser.add_argument_group("Signal options")
|
signal_grp = parser.add_argument_group("Signal options")
|
||||||
signal_grp.add_argument("--account", metavar="PHONE", help="Signal account phone number to use")
|
signal_grp.add_argument("--account", metavar="PHONE", help="Signal account phone number to use")
|
||||||
signal_grp.add_argument("--recipients", nargs="+", metavar="RECIPIENT",
|
signal_grp.add_argument("--recipients", metavar="RECIPIENTS", help="Send to Signal group ID (no --to needed)")
|
||||||
help="Send to one or more Signal group IDs / recipients (no --to needed)")
|
|
||||||
signal_grp.add_argument("--voice", metavar="AUDIO", help="Send an audio file as a Signal voice message")
|
signal_grp.add_argument("--voice", metavar="AUDIO", help="Send an audio file as a Signal voice message")
|
||||||
signal_grp.add_argument("--name", metavar="NAME", help="Device name for Signal linking (default: 'automation')")
|
signal_grp.add_argument("--name", metavar="NAME", help="Device name for Signal linking (default: 'automation')")
|
||||||
|
|
||||||
# Management commands
|
# Management commands
|
||||||
mgmt = parser.add_argument_group("Management commands")
|
mgmt = parser.add_argument_group("Management commands")
|
||||||
mgmt.add_argument("--list-signal", action="store_true", help="List linked Signal accounts")
|
mgmt.add_argument("--list-signal", action="store_true", help="List linked Signal accounts")
|
||||||
mgmt.add_argument("--list-groups", action="store_true",
|
|
||||||
help="List Signal groups (and their IDs) for the account")
|
|
||||||
mgmt.add_argument("--link-signal", action="store_true", help="Link a new device to your Signal account")
|
mgmt.add_argument("--link-signal", action="store_true", help="Link a new device to your Signal account")
|
||||||
mgmt.add_argument("--show-config", action="store_true",
|
mgmt.add_argument("--show-config", action="store_true",
|
||||||
help="Print resolved configuration and where each value came from")
|
help="Print resolved configuration and where each value came from")
|
||||||
@ -1148,13 +891,10 @@ def main():
|
|||||||
|
|
||||||
# CSV-specific
|
# CSV-specific
|
||||||
csv_grp = parser.add_argument_group("CSV options")
|
csv_grp = parser.add_argument_group("CSV options")
|
||||||
csv_grp.add_argument("--delay", metavar="SECONDS", type=float,
|
csv_grp.add_argument("--delay", metavar="SECONDS", help="Wait N seconds between CSV row sends (default: 0)")
|
||||||
help="Wait N seconds between CSV row sends (default: 0)")
|
|
||||||
csv_grp.add_argument("--dry-run", action="store_true", help="Preview a CSV batch without sending")
|
csv_grp.add_argument("--dry-run", action="store_true", help="Preview a CSV batch without sending")
|
||||||
csv_grp.add_argument("--yes", "-y", action="store_true",
|
csv_grp.add_argument("--yes", "-y", action="store_true",
|
||||||
help="Skip the large-batch confirmation prompt")
|
help="Skip the large-batch confirmation prompt")
|
||||||
csv_grp.add_argument("--json", action="store_true", dest="json_output",
|
|
||||||
help="Emit a machine-readable JSON summary (for unattended runs)")
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
@ -1167,7 +907,6 @@ def main():
|
|||||||
"--sms": args.sms,
|
"--sms": args.sms,
|
||||||
"--csv": bool(args.csv),
|
"--csv": bool(args.csv),
|
||||||
"--list-signal": args.list_signal,
|
"--list-signal": args.list_signal,
|
||||||
"--list-groups": args.list_groups,
|
|
||||||
"--link-signal": args.link_signal,
|
"--link-signal": args.link_signal,
|
||||||
"--show-config": args.show_config,
|
"--show-config": args.show_config,
|
||||||
}
|
}
|
||||||
@ -1175,7 +914,7 @@ def main():
|
|||||||
|
|
||||||
if not chosen:
|
if not chosen:
|
||||||
parser.print_help()
|
parser.print_help()
|
||||||
print("\n❌ Error: Specify --signal, --sms, --csv, --list-signal, --list-groups, --link-signal, or --show-config", file=sys.stderr)
|
print("\n❌ Error: Specify --signal, --sms, --csv, --list-signal, --link-signal, or --show-config", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
if len(chosen) > 1:
|
if len(chosen) > 1:
|
||||||
@ -1200,8 +939,6 @@ def main():
|
|||||||
cmd_csv(args)
|
cmd_csv(args)
|
||||||
elif args.list_signal:
|
elif args.list_signal:
|
||||||
cmd_list_signal(args)
|
cmd_list_signal(args)
|
||||||
elif args.list_groups:
|
|
||||||
cmd_list_groups(args)
|
|
||||||
elif args.link_signal:
|
elif args.link_signal:
|
||||||
cmd_link_signal(args)
|
cmd_link_signal(args)
|
||||||
elif args.show_config:
|
elif args.show_config:
|
||||||
|
|||||||
@ -1,444 +0,0 @@
|
|||||||
"""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