Merge pull request #4 from docwho76/v4.3.0-fix-groups-and-attachments

Update docs, fix bugs with group sending and adding attachments
This commit is contained in:
Daniel Spisak 2026-06-12 19:09:03 -07:00 committed by GitHub
commit 0c10c9cbc8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 428 additions and 231 deletions

247
README.md
View File

@ -2,7 +2,7 @@
**Universal message sender for Signal and SMS/iMessage.** **Universal message sender for Signal and SMS/iMessage.**
`sendmsg` is a command-line tool for sending messages via the [Signal REST API](https://github.com/bbernhard/signal-cli-rest-api) or locally via macOS [Messages](https://apps.apple.com/app/messages/id1092291483) (iMessage/SMS) through the [`imsg`](https://github.com/DocWho76/imsg) CLI. `sendmsg` is a command-line tool for sending messages via the [Signal REST API](https://github.com/bbernhard/signal-cli-rest-api) or locally via macOS [Messages](https://apps.apple.com/app/messages/id1092291483) (iMessage/SMS) through the [`imsg`] CLI.
It supports individual direct messages, Signal group broadcasts, file attachments, and bulk sending from CSV files — all with a single, consistent interface. It supports individual direct messages, Signal group broadcasts, file attachments, and bulk sending from CSV files — all with a single, consistent interface.
@ -14,13 +14,15 @@ It supports individual direct messages, Signal group broadcasts, file attachment
- [Dependencies](#dependencies) - [Dependencies](#dependencies)
- [Installation](#installation) - [Installation](#installation)
- [Usage](#usage) - [Usage](#usage)
- [Signal Messages](#signal-messages) * [Signal Messages](#signal-messages)
- [SMS / iMessage](#sms--imessage) * [SMS / iMessage](#sms--imessage)
- [Bulk Send from CSV](#bulk-send-from-csv) * [Bulk Send from CSV](#bulk-send-from-csv)
- [Management Commands](#management-commands) * [Management Commands](#management-commands)
- [CSV Format](#csv-format) - [CSV Format](#csv-format)
- [Configuration](#configuration) - [Configuration](#configuration)
- [Examples](#examples) - [Examples](#examples)
- [Error Handling](#error-handling)
- [Summary Output](#summary-output)
--- ---
@ -35,9 +37,9 @@ It supports individual direct messages, Signal group broadcasts, file attachment
│ ┌──────────────┐ ┌──────────────────────────┐ │ │ ┌──────────────┐ ┌──────────────────────────┐ │
│ │ --signal │ │ --sms │ │ │ │ --signal │ │ --sms │ │
│ │ │ │ │ │ │ │ │ │ │ │
│ │ POST to │ │ Call `imsg send` CLI │ │ │ │ POST JSON │ │ Call `imsg send` CLI │ │
│ │ Signal REST │ │ │ │ │ │ to Signal │ │ │ │
│ │ API │ │ │ │ │ │ REST API │ │ │ │
│ └──────┬───────┘ └────────────┬─────────────┘ │ │ └──────┬───────┘ └────────────┬─────────────┘ │
│ │ │ │ │ │ │ │
│ ▼ │ │ │ ▼ │ │
@ -51,7 +53,7 @@ It supports individual direct messages, Signal group broadcasts, file attachment
└─────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────┘
``` ```
**Signal path:** `sendmsg` → HTTP POST → `signal-cli-rest-api` → Docker container → Signal network. **Signal path:** `sendmsg`JSON HTTP POST → `signal-cli-rest-api` → Docker container → Signal network. Attachments are base64-encoded inline in the JSON request body (the API does not accept multipart uploads).
**SMS/iMessage path:** `sendmsg` → subprocess call → `imsg` CLI → macOS Messages framework → carrier/Apple. **SMS/iMessage path:** `sendmsg` → subprocess call → `imsg` CLI → macOS Messages framework → carrier/Apple.
@ -61,24 +63,26 @@ It supports individual direct messages, Signal group broadcasts, file attachment
### Required ### Required
| Dependency | Version | Purpose | | Dependency | Version | Purpose |
|---|---|---| | ---------- | ----------------- | --------------------------------- |
| Python 3 | 3.9+ | Script runtime | | Python 3 | 3.9+ | Script runtime |
| macOS 12+ | Monterey or later | Required for iMessage/SMS support | | macOS 12+ | Monterey or later | Required for iMessage/SMS support |
> `sendmsg` uses only the Python standard library — no third-party packages to install.
### Signal (optional — only needed for `--signal`) ### Signal (optional — only needed for `--signal`)
| Dependency | Version | Purpose | | Dependency | Version | Purpose |
|---|---|---| | ---------------------------- | ----------------------------- | ------------------------------ |
| Docker | 24+ (Colima on Apple Silicon) | Container runtime | | Docker | 24+ | Container runtime |
| `signal-cli-rest-api` | latest | Signal REST API server | | `signal-cli-rest-api` | latest | Signal REST API server |
| `signal-cli` (via container) | latest | Signal protocol implementation | | `signal-cli` (via container) | latest | Signal protocol implementation |
### SMS/iMessage (optional — only needed for `--sms`) ### SMS/iMessage (optional — only needed for `--sms`)
| Dependency | Version | Purpose | | Dependency | Version | Purpose |
|---|---|---| | ---------- | ------- | --------------------------- |
| `imsg` CLI | latest | macOS Messages / SMS bridge | | `imsg` CLI | latest | macOS Messages / SMS bridge |
### System ### System
@ -91,30 +95,33 @@ It supports individual direct messages, Signal group broadcasts, file attachment
1. **Place the script:** 1. **Place the script:**
```bash ```
cp sendmsg /usr/local/bin/sendmsg cp sendmsg /usr/local/bin/sendmsg
chmod +x /usr/local/bin/sendmsg chmod +x /usr/local/bin/sendmsg
``` ```
2. **Ensure dependencies are installed:** 2. **Ensure dependencies are installed:**
**For Signal:** **For Signal:**
```bash
# Verify signal-cli-rest-api is running ```
curl -s http://localhost:8080/v1/accounts | python3 -m json.tool # Verify signal-cli-rest-api is running
``` curl -s http://localhost:8080/v1/accounts | python3 -m json.tool
```
**For SMS/iMessage:** **For SMS/iMessage:**
```bash
# Verify imsg is installed and available ```
imsg send --help # Verify imsg is installed and available
``` imsg send --help
```
3. **Test the script:** 3. **Test the script:**
```bash ```
sendmsg --help sendmsg --help
``` sendmsg --show-config # confirm where settings are being read from
```
--- ---
@ -131,11 +138,12 @@ Messaging methods (choose one):
Management commands (choose one): Management commands (choose one):
--list-signal List linked Signal accounts --list-signal List linked Signal accounts
--link-signal Link a new device to your Signal account --link-signal Link a new device to your Signal account
--show-config Print resolved configuration and where each value came from
``` ```
### Signal Messages ### Signal Messages
```bash ```
# Send a text message # Send a text message
sendmsg --signal --to +18885551212 --text "Hello from Signal!" sendmsg --signal --to +18885551212 --text "Hello from Signal!"
@ -147,11 +155,18 @@ sendmsg --signal --to +18885551212 --text "Check this out" --attach ~/photo.jpg
# Send multiple messages (concatenated with newlines) # Send multiple messages (concatenated with newlines)
sendmsg --signal --to +18885551212 --text "Line 1" --text "Line 2" sendmsg --signal --to +18885551212 --text "Line 1" --text "Line 2"
# Send multiple attachments
sendmsg --signal --to +18885551212 --text "Files" --attach ~/doc.pdf ~/pic.jpg
``` ```
> Attachments are read, base64-encoded, and sent inside the JSON request to
> the Signal REST API. The original filename and detected MIME type are
> preserved so recipients see the correct file name and type.
### SMS / iMessage ### SMS / iMessage
```bash ```
# Send via iMessage (auto-detected) # Send via iMessage (auto-detected)
sendmsg --sms --to +18885551212 --text "Hello via iMessage!" sendmsg --sms --to +18885551212 --text "Hello via iMessage!"
@ -164,20 +179,24 @@ sendmsg --sms --to +18885551212 --text "Photo attached" --file ~/photo.jpg
### Bulk Send from CSV ### Bulk Send from CSV
```bash ```
sendmsg --csv messages.csv # Send all rows sendmsg --csv messages.csv # Send all rows
sendmsg --csv messages.csv --delay 2 # Wait 2 seconds between sends sendmsg --csv messages.csv --delay 2 # Wait 2 seconds between every send
``` ```
### Management Commands ### Management Commands
```bash ```
# List linked Signal accounts # List linked Signal accounts
sendmsg --list-signal sendmsg --list-signal
# Link a new device # Link a new device
sendmsg --link-signal sendmsg --link-signal
sendmsg --link-signal --name "my-laptop" sendmsg --link-signal --name "my-laptop"
# Show the active configuration and its sources
sendmsg --show-config
sendmsg --show-config -v # also checks whether the Signal REST API is reachable
``` ```
--- ---
@ -186,25 +205,31 @@ sendmsg --link-signal --name "my-laptop"
Create a CSV file with the following columns: Create a CSV file with the following columns:
| Column | Required | Description | | Column | Required | Description |
|---|---|---| | ----------- | -------- | ----------------------------------------------------------------------------- |
| `method` | Yes | `signal` or `sms` | | `method` | Yes | `signal` or `sms` (defaults to `signal` if left blank) |
| `recipient` | Yes | Phone number, group ID (for Signal), or empty for group sends | | `recipient` | Yes | Phone number, or a Signal group ID (`group.XXXX` or a raw group key) |
| `name` | No | Display name shown during status output (e.g., "Alice", "Marketing Group") | | `name` | No | Display name shown during status output (e.g., "Alice", "Marketing Group") |
| `message` | Yes | The message text to send | | `message` | Yes | The message text to send |
| `account` | No | Signal account phone number (defaults to `+156****1603` or `$SIGNAL_ACCOUNT`) | | `account` | No | Signal account phone number (defaults to `$SIGNAL_ACCOUNT` / config value) |
| `service` | No | SMS service: `imessage`, `sms`, or `auto` (SMS only) | | `service` | No | SMS service: `imessage`, `sms`, or `auto` (SMS only) |
| `file` | No | Path to an attachment file (relative or absolute) | | `file` | No | Path to an attachment file. `~` and environment variables are expanded. |
| `delay` | No | Seconds to wait after this row is sent (e.g., `3`) | | `delay` | No | Seconds to wait after this row is sent (overrides the global `--delay`). |
**Notes**
- A `message` is required on every row; rows with no message are skipped and reported.
- Group recipients are auto-detected: any `recipient` that is not a `+`-prefixed phone number is treated as a Signal group ID.
- File paths starting with `~` are expanded to the home directory of the user running the script. If a named attachment cannot be found, a warning is printed and the message is still sent without it.
### Example CSV ### Example CSV
```csv ```
method,recipient,name,message,account,service,file,delay method,recipient,name,message,account,service,file,delay
signal,+156****1603,Alice,Hello via Signal,+156****1603,,, signal,+18885551111,Alice,Hello via Signal,+18885551111,,,
signal,group.ZzBHd3NZO...,Team Alert,,,,-1 signal,group.ZzBHd3NZO...,Team Alert,Morning update for the team,,,,
sms,+123****7890,Bob,SMS test,,,~/pic.jpg, sms,+18885552222,Bob,SMS test,,,~/pic.jpg,
signal,+156****1603,Carol,With delay,,,-3 signal,+18885553333,Carol,With a delay after this row,,,,3
``` ```
### Status Output ### Status Output
@ -212,10 +237,10 @@ signal,+156****1603,Carol,With delay,,,-3
During a bulk send, each row prints its name (if provided): During a bulk send, each row prints its name (if provided):
``` ```
[1/4] SIGNAL → Alice (+156****1603) [1/4] SIGNAL → Alice (+18885551111)
[2/4] SIGNAL → Team Alert (group.ZzBHd3NZO...) [2/4] SIGNAL → Team Alert (group.ZzBHd3NZO...)
[3/4] SMS → Bob (+123****7890) [3/4] SMS → Bob (+18885552222)
[4/4] SIGNAL → Carol (+156****1603) [4/4] SIGNAL → Carol (+18885553333)
``` ```
If no `name` is provided, the recipient is shown instead. If no `name` is provided, the recipient is shown instead.
@ -224,23 +249,55 @@ If no `name` is provided, the recipient is shown instead.
## Configuration ## Configuration
### Load settings from sendmsg config file in ~/.sendmsg.conf, with env var fallback. Settings are read from a config file at `~/.sendmsg.conf`, with environment
variable and built-in fallbacks.
Config file format (INI-style): **Resolution order:** environment variable → config file → built-in default.
[settings]
signal_rest_url = http://localhost:8080 ### Config file format (INI-style)
signal_default_account = +1234567890
```
[settings]
signal_rest_url = http://localhost:8080
signal_default_account = +1234567890
```
### Environment Variables ### Environment Variables
| Variable | Default | Description | | Variable | Default | Description |
|---|---|---| | ----------------- | ----------------------- | ----------------------------------- |
| `SIGNAL_REST_URL` | `http://localhost:8080` | URL of the Signal REST API server | | `SIGNAL_REST_URL` | `http://localhost:8080` | URL of the Signal REST API server |
| `SIGNAL_ACCOUNT` | `+1234567890` | Default Signal account phone number | | `SIGNAL_ACCOUNT` | `+1234567890` | Default Signal account phone number |
### Inspecting the active configuration
Use `--show-config` to print the resolved values and exactly where each one
came from (environment, config file, or built-in default):
```
$ sendmsg --show-config
==================================================
⚙️ sendmsg configuration
==================================================
Version: 4.3.0
Config file: /Users/you/.sendmsg.conf (found)
signal_rest_url: http://localhost:8080
└─ source: config (/Users/you/.sendmsg.conf)
signal_default_account: +18885551212
└─ source: env ($SIGNAL_ACCOUNT)
Resolution order: env var > config file > built-in default
==================================================
```
Add `-v` to also probe whether the Signal REST API is currently reachable.
### Override Example ### Override Example
```bash ```
export SIGNAL_REST_URL=http://localhost:8082 export SIGNAL_REST_URL=http://localhost:8082
export SIGNAL_ACCOUNT=+18885551212 export SIGNAL_ACCOUNT=+18885551212
sendmsg --csv messages.csv sendmsg --csv messages.csv
@ -252,36 +309,42 @@ sendmsg --csv messages.csv
### Daily Broadcast ### Daily Broadcast
```csv ```
method,recipient,name,message,account,service,file,delay method,recipient,name,message,account,service,file,delay
signal,group.ZzBHd3NZO...,Daily Update,Good morning team! Here's your daily briefing.,+18002222222,,, signal,group.ZzBHd3NZO...,Daily Update,Good morning team! Here's your daily briefing.,+18002222222,,,
``` ```
### Personalized Outreach ### Personalized Outreach
```csv
method,recipient,name,message,account,service,file,delay
signal,+18885551111,John,Hi John, hope you're doing well!
sms,+18885552222,Jane,Hey Jane, just checking in!
signal,+18885553333,Alex,Alex, don't forget about the meeting tomorrow at 3pm!
``` ```
method,recipient,name,message,account,service,file,delay
signal,+18885551111,John,Hi John hope you're doing well,,,,
sms,+18885552222,Jane,Hey Jane just checking in,,,,
signal,+18885553333,Alex,Alex don't forget the meeting tomorrow at 3pm,,,,
```
> Avoid commas inside the `message` field unless the field is quoted, since
> commas are the CSV column separator.
### With Attachments ### With Attachments
```csv ```
method,recipient,name,message,account,service,file,delay method,recipient,name,message,account,service,file,delay
signal,+18885551111,John,Here's the report you asked for,+18002222222,,,~/Downloads/report.pdf signal,+18885551111,John,Here's the report you asked for,+18002222222,,~/Downloads/report.pdf,
sms,+18885552222,Jane,Photo from the event,,,~/Photos/event.jpg, sms,+18885552222,Jane,Photo from the event,,,~/Photos/event.jpg,
``` ```
### With Delays ### With Delays
```csv
method,recipient,name,message,account,service,file,delay
signal,+18885551111,Alice,First message,,,
signal,+18885552222,Bob,Second message,,,3
signal,+18885553333,Charlie,Third message,,,
``` ```
method,recipient,name,message,account,service,file,delay
signal,+18885551111,Alice,First message,,,,
signal,+18885552222,Bob,Second message after a 3s pause,,,,3
signal,+18885553333,Charlie,Third message,,,,
```
A per-row `delay` value takes precedence over the global `--delay` flag for
that row.
--- ---
@ -289,6 +352,7 @@ signal,+18885553333,Charlie,Third message,,,
- **Unknown method:** Rows with invalid `method` values are skipped and reported in the summary. - **Unknown method:** Rows with invalid `method` values are skipped and reported in the summary.
- **Empty message:** Rows without a `message` are skipped and reported. - **Empty message:** Rows without a `message` are skipped and reported.
- **Missing attachment:** If a named file cannot be found, a warning is printed and the message is sent without the attachment.
- **Failed sends:** Failed attempts are counted and listed in the summary. - **Failed sends:** Failed attempts are counted and listed in the summary.
- **Exit codes:** The script exits with `1` if any rows fail, `0` on full success. - **Exit codes:** The script exits with `1` if any rows fail, `0` on full success.
@ -313,4 +377,21 @@ After processing all rows, a summary is printed:
--- ---
*For support or issues, see https://xkcd.com/627/* ## Changelog
### 4.3.0
- **Fixed:** Signal attachments now send correctly. Files are base64-encoded and delivered in the JSON request body via `base64_attachments`; the previous multipart upload was rejected by the Signal REST API with HTTP 400.
- Attachment filename and MIME type are now preserved using a data-URI form.
### 4.2.0
- **Added:** `--show-config` to report resolved settings and their sources (`-v` also checks REST API reachability).
- Config resolution rewritten to correctly source-track values and avoid empty values silently falling through to defaults.
### 4.1.0
- **Fixed:** CSV attachment paths using `~` are now expanded, so Signal/SMS attachments from CSV rows are no longer silently dropped.
- **Fixed (issue #3):** Group messages are detected robustly; raw (non-`group.`) group IDs are no longer mis-sent down the direct-message path.
- **Fixed:** Multipart text/bytes join crash; consistent `+` normalization of account numbers; global `--delay` now applies between rows; missing files are skipped with a warning instead of being passed to the sender; duplicate/dead Signal send branches collapsed.
---
*For support or issues, see <https://xkcd.com/627/>*

410
sendmsg
View File

@ -3,29 +3,31 @@
sendmsg — Universal message sender for Signal and SMS/iMessage. sendmsg — Universal message sender for Signal and SMS/iMessage.
Usage: Usage:
sendmsg --signal --to +18005551212 --text "Hello from Signal!" sendmsg --signal --to +18005551212 --text "Hello from Signal!"
sendmsg --signal --recipients group_id_here --text "All members" sendmsg --signal --recipients group_id_here --text "All members"
sendmsg --sms --to +18005551212 --text "Hello via SMS!" sendmsg --sms --to +18005551212 --text "Hello via SMS!"
sendmsg --sms --to +18005551212 --text "Hey" --file ~/photo.png sendmsg --sms --to +18005551212 --text "Hey" --file ~/photo.png
sendmsg --signal --to +18005551212 --text "Multi" --text "more text" sendmsg --signal --to +18005551212 --text "Multi" --text "more text"
sendmsg --signal --to +18005551212 --attach ~/doc.pdf ~/pic.jpg sendmsg --signal --to +18005551212 --attach ~/doc.pdf ~/pic.jpg
sendmsg --csv messages.csv # Bulk send from CSV file sendmsg --csv messages.csv # Bulk send from CSV file
sendmsg --csv messages.csv --delay 2 # Wait N seconds between sends sendmsg --csv messages.csv --delay 2 # Wait N seconds between sends
sendmsg --list-signal # Show linked Signal accounts sendmsg --list-signal # Show linked Signal accounts
sendmsg --link-signal # Link a new device to your existing Signal account sendmsg --link-signal # Link a new device to your existing Signal account
CSV format (messages.csv): CSV format (messages.csv):
method,recipient,name,message,account,service,file,delay method,recipient,name,message,account,service,file,delay
signal,+1888222333,Alice,Hello via Signal,+1888222333,,, signal,+1888222333,Alice,Hello via Signal,+1888222333,,,
signal,group.ZzBHd3NZO...,Group alert,,,,-1 signal,group.ZzBHd3NZO...,Group alert,Hi team,,,,
sms,+123****7890,Bob,SMS test,,,~/pic.jpg, sms,+1234567890,Bob,SMS test,,,~/pic.jpg,
signal,+1888222333,Carol,With delay,,,-3 signal,+1888222333,Carol,With delay,,,,3
""" """
import argparse import argparse
import base64
import configparser import configparser
import csv import csv
import json import json
import mimetypes
import os import os
import subprocess import subprocess
import sys import sys
@ -34,7 +36,8 @@ import urllib.error
import urllib.parse import urllib.parse
import urllib.request import urllib.request
VERSION = "4.0.0" VERSION = "4.3.0"
CONFIG_PATHS = [ CONFIG_PATHS = [
os.path.expanduser("~/.sendmsg.conf"), os.path.expanduser("~/.sendmsg.conf"),
] ]
@ -54,23 +57,36 @@ def load_config():
"signal_rest_url": "http://localhost:8080", "signal_rest_url": "http://localhost:8080",
"signal_default_account": "+1234567890", "signal_default_account": "+1234567890",
} }
config = configparser.ConfigParser() env_vars = {
"signal_rest_url": "SIGNAL_REST_URL",
"signal_default_account": "SIGNAL_ACCOUNT",
}
config = configparser.ConfigParser()
config_path = None
for path in CONFIG_PATHS: for path in CONFIG_PATHS:
if os.path.isfile(path): if os.path.isfile(path):
config.read(path) config.read(path)
config_path = path
break break
return { resolved = {}
"signal_rest_url": ( sources = {}
os.environ.get("SIGNAL_REST_URL") for key, default in defaults.items():
or config.get("settings", "signal_rest_url", fallback=defaults["signal_rest_url"]) env_val = os.environ.get(env_vars[key])
), if env_val:
"signal_default_account": ( resolved[key] = env_val
os.environ.get("SIGNAL_ACCOUNT") sources[key] = f"env (${env_vars[key]})"
or config.get("settings", "signal_default_account", fallback=defaults["signal_default_account"]) elif config.has_option("settings", key):
), resolved[key] = config.get("settings", key)
} sources[key] = f"config ({config_path})"
else:
resolved[key] = default
sources[key] = "default"
resolved["_sources"] = sources
resolved["_config_path"] = config_path
return resolved
# Load config at module level for use by all functions # Load config at module level for use by all functions
@ -83,23 +99,87 @@ SIGNAL_DEFAULT_ACCOUNT = CFG["signal_default_account"]
# Helpers — reusable without argparse objects (needed for CSV dispatch) # Helpers — reusable without argparse objects (needed for CSV dispatch)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def expand_path(path):
"""Expand '~' and environment variables in a file path.
CSV rows supply literal paths like '~/pic.jpg'; unlike the shell, Python
does not expand the tilde, so os.path.isfile('~/pic.jpg') is always False.
This caused CSV Signal attachments to be silently dropped.
"""
if not path:
return path
return os.path.expanduser(os.path.expandvars(path.strip()))
def normalize_account(account):
"""Ensure an account/phone number carries a leading '+'.
Group IDs (starting with 'group.') are returned unchanged.
"""
if not account:
return account
account = account.strip()
if account.startswith("group."):
return account
if not account.startswith("+"):
account = "+" + account
return account
def is_group_id(value):
"""Return True if a recipient value looks like a Signal group ID.
signal-cli group IDs are either the 'group.<base64>' form used by the
REST API, or a raw base64 internal group key. Sending a group message
down the 1:1 'recipient' path is what causes the 'Unregistered user
<uuid>' errors and the wrong-sender appearance in issue #3, so we treat
anything that is not a phone number as a group ID.
"""
if not value:
return False
value = value.strip()
if value.startswith("group."):
return True
# Phone numbers start with '+' followed by digits. Anything else that
# is not a phone number is assumed to be a (raw) group ID.
if value.startswith("+") and value[1:].isdigit():
return False
return True
def send_one_signal(account, recipient, message, group_id=None, attach=None): def send_one_signal(account, recipient, message, group_id=None, attach=None):
"""Send a single Signal message. Returns True on success.""" """Send a single Signal message. Returns True on success."""
payload = {"number": account} account = normalize_account(account)
payload = {"number": account}
if group_id: if group_id:
payload["recipients"] = [group_id] payload["recipients"] = [group_id]
else: else:
payload["recipient"] = recipient payload["recipients"] = [recipient]
if message: if message:
payload["message"] = message payload["message"] = message
# Attachments # Expand '~' / env vars; CSV supplies literal paths the shell never saw.
if attach and os.path.isfile(attach): attach = expand_path(attach) if attach else None
# If an attachment was specified but does not exist, warn loudly rather
# than silently sending text-only.
if attach and not os.path.isfile(attach):
print(f"⚠️ Warning: attachment not found, sending without it: {attach}", file=sys.stderr)
attach = None
has_attachment = bool(attach)
if not message and not has_attachment:
print(
f"❌ Signal send skipped: no message text and no valid attachment "
f"(recipient: {group_id or recipient})",
file=sys.stderr,
)
return False
if has_attachment:
result = signal_rest_post("/v2/send", payload=payload, files=[attach]) result = signal_rest_post("/v2/send", payload=payload, files=[attach])
elif message and not group_id:
result = signal_rest_post("/v2/send", payload=payload)
else: else:
result = signal_rest_post("/v2/send", payload=payload) result = signal_rest_post("/v2/send", payload=payload)
@ -109,17 +189,17 @@ def send_one_signal(account, recipient, message, group_id=None, attach=None):
def send_one_sms(to, message, service=None, file=None): def send_one_sms(to, message, service=None, file=None):
"""Send a single SMS/iMessage. Returns True on success.""" """Send a single SMS/iMessage. Returns True on success."""
cmd = ["imsg", "send", "--to", to] cmd = ["imsg", "send", "--to", to]
if service: if service:
cmd.extend(["--service", service]) cmd.extend(["--service", service])
cmd.extend(["--text", message]) cmd.extend(["--text", message])
if file:
if file and os.path.isfile(file): file = expand_path(file)
cmd.extend(["--file", file]) if os.path.isfile(file):
cmd.extend(["--file", file])
else:
print(f"⚠️ Warning: file not found, skipping attachment: {file}", file=sys.stderr)
result = subprocess.run(cmd, capture_output=True, text=True) result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode == 0: if result.returncode == 0:
service_label = service.upper() if service else "AUTO" service_label = service.upper() if service else "AUTO"
print(f"✅ SMS/iMessage sent to {to} (service: {service_label})") print(f"✅ SMS/iMessage sent to {to} (service: {service_label})")
@ -134,54 +214,42 @@ def send_one_sms(to, message, service=None, file=None):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def signal_rest_post(endpoint, payload=None, files=None): def signal_rest_post(endpoint, payload=None, files=None):
"""Post JSON or multipart form data to the signal-cli-rest-api. """Post a JSON request to the signal-cli-rest-api.
The /v2/send endpoint expects a single JSON body. Attachments are NOT
sent as multipart/form-data — they must be base64-encoded and placed in
the "base64_attachments" array, each as a data URI of the form
'data:<mime>;filename=<name>;base64,<data>'. Sending multipart produces
HTTP 400 "invalid request".
Returns the parsed JSON response dict, or None on failure. Returns the parsed JSON response dict, or None on failure.
""" """
url = f"{SIGNAL_REST_URL.rstrip('/')}{endpoint}" url = f"{SIGNAL_REST_URL.rstrip('/')}{endpoint}"
if files is not None and files: payload = dict(payload) if payload else {}
# Multipart form data for attachments
boundary = "----sendmsg-boundary"
body_parts = []
if payload:
for key, value in payload.items():
body_parts.append(
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="{key}"\r\n\r\n'
f"{value}\r\n"
)
if files:
b64_attachments = []
for fpath in files: for fpath in files:
fpath = expand_path(fpath)
if not os.path.isfile(fpath): if not os.path.isfile(fpath):
print(f"⚠️ Warning: attachment not found: {fpath}", file=sys.stderr) print(f"⚠️ Warning: attachment not found: {fpath}", file=sys.stderr)
continue continue
with open(fpath, "rb") as fh: with open(fpath, "rb") as fh:
content = fh.read() encoded = base64.b64encode(fh.read()).decode("ascii")
fname = os.path.basename(fpath) fname = os.path.basename(fpath)
body_parts.append( mime = mimetypes.guess_type(fpath)[0] or "application/octet-stream"
f"--{boundary}\r\n" # Data-URI form preserves both MIME type and filename.
f"Content-Disposition: form-data; name=\"attachment\"; filename=\"{fname}\"\r\n" b64_attachments.append(f"data:{mime};filename={fname};base64,{encoded}")
f"Content-Type: application/octet-stream\r\n\r\n"
)
body_parts.append(content)
body_parts.append(b"\r\n")
body_parts.append(f"--{boundary}--\r\n".encode("utf-8")) if b64_attachments:
full_body = b"".join(body_parts) payload["base64_attachments"] = b64_attachments
headers = { body = json.dumps(payload).encode("utf-8")
"Content-Type": f"multipart/form-data; boundary={boundary}", headers = {"Content-Type": "application/json"}
} full_body = body
else:
# JSON payload
body = json.dumps(payload).encode("utf-8") if payload else b"{}"
headers = {"Content-Type": "application/json"}
full_body = body
req = urllib.request.Request(url, data=full_body, headers=headers, method="POST") req = urllib.request.Request(url, data=full_body, headers=headers, method="POST")
try: try:
with urllib.request.urlopen(req, timeout=30) as resp: with urllib.request.urlopen(req, timeout=30) as resp:
resp_data = resp.read().decode("utf-8") resp_data = resp.read().decode("utf-8")
@ -194,7 +262,7 @@ def signal_rest_post(endpoint, payload=None, files=None):
) )
return None return None
return result return result
return None return {}
except urllib.error.HTTPError as e: except urllib.error.HTTPError as e:
body = e.read().decode("utf-8", errors="replace") body = e.read().decode("utf-8", errors="replace")
print( print(
@ -236,59 +304,58 @@ def cmd_signal(args):
if not args.to and not args.recipients: if not args.to and not args.recipients:
print("❌ Error: --to <phone> is required for direct messages, or use --recipients for groups.", file=sys.stderr) print("❌ Error: --to <phone> is required for direct messages, or use --recipients for groups.", file=sys.stderr)
sys.exit(1) sys.exit(1)
if not args.text and not getattr(args, "attach", None) and not args.recipients:
attach_files = getattr(args, "attach", None) or getattr(args, "file", None)
# Text or an attachment is required in ALL cases, including group sends.
if not args.text and not attach_files:
print("❌ Error: --text <message> or --attach <file> is required.", file=sys.stderr) print("❌ Error: --text <message> or --attach <file> is required.", file=sys.stderr)
sys.exit(1) sys.exit(1)
account = args.account or SIGNAL_DEFAULT_ACCOUNT account = normalize_account(args.account or SIGNAL_DEFAULT_ACCOUNT)
if not account.startswith("+"):
account = "+" + account
# Build the JSON payload for /v2/send # Build the JSON payload for /v2/send
payload = {"number": account} payload = {"number": account}
if args.recipients: if args.recipients:
# Send to a Signal group — use recipients array with group. prefix # Send to a Signal group — use recipients array with the group ID.
payload["recipients"] = [args.recipients] payload["recipients"] = [args.recipients]
else: else:
# Send to individual recipients (string) # Send to an individual recipient. The REST API accepts a
payload["recipient"] = args.to[0] # recipients array for direct messages too; using it consistently
# avoids the 1:1-vs-group dispatch mismatch behind issue #3.
payload["recipients"] = [args.to[0]]
# Message text (join multiple --text args) # Message text (join multiple --text args)
if args.text: if args.text:
payload["message"] = "\n".join(args.text) payload["message"] = "\n".join(args.text)
# Attachments # Attachments
attach_files = getattr(args, "attach", None)
if attach_files and len(attach_files) > 0: if attach_files and len(attach_files) > 0:
if len(attach_files) == 1: if len(attach_files) == 1:
# Single file — send as multipart with message # Single file — send as multipart with the message.
result = signal_rest_post("/v2/send", payload=payload, files=attach_files) result = signal_rest_post("/v2/send", payload=payload, files=attach_files)
else: else:
# Multiple files: send message first, then each attachment separately # Multiple files: send the text first, then each attachment.
multi_payload = {"number": account, "message": payload.get("message", "")} multi_payload = {"number": account, "message": payload.get("message", "")}
if args.recipients: multi_payload["recipients"] = payload["recipients"]
multi_payload["recipients"] = [args.recipients]
else: # Only send a standalone text message if there is actual text.
multi_payload["recipient"] = args.to[0] result = None
result = signal_rest_post("/v2/send", payload=multi_payload) if multi_payload["message"]:
if result is None: result = signal_rest_post("/v2/send", payload=multi_payload)
sys.exit(1)
# Then send each attachment
for fpath in attach_files:
att_payload = {"number": account}
if args.recipients:
att_payload["recipients"] = [args.recipients]
else:
att_payload["recipient"] = args.to[0]
result = signal_rest_post("/v2/send", payload=att_payload, files=[fpath])
if result is None: if result is None:
print(f"⚠️ Attachment failed: {fpath}", file=sys.stderr) sys.exit(1)
elif args.text and not args.recipients:
result = signal_rest_post("/v2/send", payload=payload) # Then send each attachment.
for fpath in attach_files:
att_payload = {"number": account, "recipients": payload["recipients"]}
att_result = signal_rest_post("/v2/send", payload=att_payload, files=[fpath])
if att_result is None:
print(f"⚠️ Attachment failed: {fpath}", file=sys.stderr)
else:
result = att_result
else: else:
# Group message or other case # Text-only send (direct or group).
result = signal_rest_post("/v2/send", payload=payload) result = signal_rest_post("/v2/send", payload=payload)
if result is not None: if result is not None:
@ -298,8 +365,7 @@ def cmd_signal(args):
print(f"✅ Signal message sent to {args.to[0]}") print(f"✅ Signal message sent to {args.to[0]}")
else: else:
print("✅ Signal message sent") print("✅ Signal message sent")
if args.verbose and result: if args.verbose and isinstance(result, dict):
# Print any extra details from the API response
for key, value in result.items(): for key, value in result.items():
if key not in ("success",): if key not in ("success",):
print(f" {key}: {value}") print(f" {key}: {value}")
@ -318,21 +384,21 @@ def cmd_sms(args):
sys.exit(1) sys.exit(1)
cmd = ["imsg", "send", "--to", args.to[0]] cmd = ["imsg", "send", "--to", args.to[0]]
if args.service: if args.service:
cmd.extend(["--service", args.service]) cmd.extend(["--service", args.service])
msg_text = "\n".join(args.text) msg_text = "\n".join(args.text)
cmd.extend(["--text", msg_text]) cmd.extend(["--text", msg_text])
if getattr(args, "file", None): attach_files = getattr(args, "file", None) or getattr(args, "attach", None)
for f in args.file: if attach_files:
for f in attach_files:
f = expand_path(f)
if not os.path.isfile(f): if not os.path.isfile(f):
print(f"⚠️ Warning: file not found: {f}", file=sys.stderr) print(f"⚠️ Warning: file not found, skipping: {f}", file=sys.stderr)
continue
cmd.extend(["--file", f]) cmd.extend(["--file", f])
result = subprocess.run(cmd, capture_output=True, text=True) result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode == 0: if result.returncode == 0:
service_label = args.service.upper() if args.service else "AUTO" service_label = args.service.upper() if args.service else "AUTO"
print(f"✅ SMS/iMessage sent to {args.to[0]} (service: {service_label})") print(f"✅ SMS/iMessage sent to {args.to[0]} (service: {service_label})")
@ -340,21 +406,23 @@ def cmd_sms(args):
print(f"❌ SMS send failed (exit {result.returncode})", file=sys.stderr) print(f"❌ SMS send failed (exit {result.returncode})", file=sys.stderr)
if result.stderr: if result.stderr:
print(f" {result.stderr.strip()}", file=sys.stderr) print(f" {result.stderr.strip()}", file=sys.stderr)
sys.exit(1)
def cmd_csv(args): def cmd_csv(args):
"""Bulk send messages from a CSV file. """Bulk send messages from a CSV file.
Expected CSV columns: Expected CSV columns:
method, recipient, name, message, account, service, file, delay method, recipient, name, message, account, service, file, delay
""" """
filepath = args.csv filepath = args.csv
if not os.path.isfile(filepath): if not os.path.isfile(filepath):
print(f"❌ Error: file not found: {filepath}", file=sys.stderr) print(f"❌ Error: file not found: {filepath}", file=sys.stderr)
sys.exit(1) sys.exit(1)
delay = float(args.delay) if args.delay else 0.0 # Global delay from --delay applies between every send unless a row
# specifies its own delay (which takes precedence).
global_delay = float(args.delay) if args.delay else 0.0
with open(filepath, newline="", encoding="utf-8") as csvfile: with open(filepath, newline="", encoding="utf-8") as csvfile:
reader = csv.DictReader(csvfile) reader = csv.DictReader(csvfile)
@ -376,20 +444,20 @@ def cmd_csv(args):
name = row.get("name", "").strip() name = row.get("name", "").strip()
# Skip blank rows # Skip blank rows
if not method and not message: if not method and not message and not to:
continue continue
if not method: if not method:
method = "signal" # default method = "signal" # default
if method not in ("signal", "sms"): if method not in ("signal", "sms"):
print(f"⚠️ Row {i}: unknown method '{method}' — skipping", file=sys.stderr) print(f"⚠️ Row {i}: unknown method '{method}' — skipping", file=sys.stderr)
failed += 1 failed += 1
errors.append(f"Row {i}: unknown method '{method}'") errors.append(f"Row {i}: unknown method '{method}'")
continue continue
if not message: if not message:
print(f"⚠️ Row {i}: empty message — skipping", file=sys.stderr) print(f"⚠️ Row {i}: empty message — skipping", file=sys.stderr)
failed += 1 failed += 1
errors.append(f"Row {i}: empty message") errors.append(f"Row {i}: empty message")
continue continue
@ -398,14 +466,21 @@ def cmd_csv(args):
account = row.get("account", "").strip() or SIGNAL_DEFAULT_ACCOUNT account = row.get("account", "").strip() or SIGNAL_DEFAULT_ACCOUNT
service = row.get("service", "").strip() or None service = row.get("service", "").strip() or None
file_path = row.get("file", "").strip() or None file_path = row.get("file", "").strip() or None
delay_str = row.get("delay", "0").strip() delay_str = row.get("delay", "").strip()
row_delay = float(delay_str) if delay_str else 0.0 try:
row_delay = float(delay_str) if delay_str else None
except ValueError:
print(f"⚠️ Row {i}: invalid delay '{delay_str}' — ignoring", file=sys.stderr)
row_delay = None
label = f"{name} ({to})" if name else to or "(group)" label = f"{name} ({to})" if name else to or "(group)"
print(f"[{i}/{total}] {method.upper()} → {label}") print(f"[{i}/{total}] {method.upper()} → {label}")
if method == "signal": if method == "signal":
group_id = to if to.startswith("group.") else None # Detect groups robustly (issue #3): raw base64 group IDs do NOT
# start with 'group.' and were previously sent down the 1:1
# recipient path, producing 'Unregistered user <uuid>' errors.
group_id = to if is_group_id(to) else None
result = send_one_signal( result = send_one_signal(
account=account, account=account,
recipient=to, recipient=to,
@ -413,21 +488,13 @@ def cmd_csv(args):
group_id=group_id, group_id=group_id,
attach=file_path, attach=file_path,
) )
elif method == "sms": else: # sms
result = send_one_sms( result = send_one_sms(
to=to, to=to,
message=message, message=message,
service=service, service=service,
file=file_path, file=file_path,
) )
if result:
success += 1
else:
failed += 1
errors.append(f"Row {i}: SMS failed")
if row_delay > 0:
time.sleep(row_delay)
continue
if result: if result:
success += 1 success += 1
@ -435,9 +502,10 @@ def cmd_csv(args):
failed += 1 failed += 1
errors.append(f"Row {i}: failed to send") errors.append(f"Row {i}: failed to send")
# Delay between sends (only if explicitly set in row) # Per-row delay overrides global; otherwise use the global --delay.
if row_delay > 0: effective_delay = row_delay if row_delay is not None else global_delay
time.sleep(row_delay) if effective_delay > 0:
time.sleep(effective_delay)
# Summary # Summary
print() print()
@ -458,6 +526,46 @@ def cmd_csv(args):
sys.exit(1) sys.exit(1)
def cmd_show_config(args):
"""Report the resolved configuration and where each value came from."""
sources = CFG.get("_sources", {})
config_path = CFG.get("_config_path")
print("=" * 50)
print("⚙️ sendmsg configuration")
print("=" * 50)
print(f" Version: {VERSION}")
print()
if config_path:
print(f" Config file: {config_path} (found)")
else:
searched = ", ".join(CONFIG_PATHS)
print(f" Config file: none found (searched: {searched})")
print()
print(f" signal_rest_url: {SIGNAL_REST_URL}")
print(f" └─ source: {sources.get('signal_rest_url', 'unknown')}")
print()
print(f" signal_default_account: {SIGNAL_DEFAULT_ACCOUNT}")
print(f" └─ source: {sources.get('signal_default_account', 'unknown')}")
print()
print(" Resolution order: env var > config file > built-in default")
print("=" * 50)
# Optionally probe whether the Signal REST API is reachable.
if getattr(args, "verbose", False):
print()
print(" Checking Signal REST API reachability...")
result = signal_rest_get("/v1/accounts")
if result is None:
print(f" ❌ Not reachable at {SIGNAL_REST_URL}")
else:
print(f" ✅ Reachable at {SIGNAL_REST_URL}")
print("=" * 50)
def cmd_list_signal(args): def cmd_list_signal(args):
"""List linked Signal accounts via the REST API.""" """List linked Signal accounts via the REST API."""
result = signal_rest_get("/v1/accounts") result = signal_rest_get("/v1/accounts")
@ -491,18 +599,19 @@ def cmd_link_signal(args):
name = args.name or "automation" name = args.name or "automation"
payload = {"name": name} payload = {"name": name}
result = signal_rest_post("/v1/link", payload=payload) result = signal_rest_post("/v1/link", payload=payload)
if result is None: if result is None:
print("❌ Failed to initiate device linking.", file=sys.stderr) print("❌ Failed to initiate device linking.", file=sys.stderr)
sys.exit(1) sys.exit(1)
sgnl_url = result.get("url", result.get("linkUrl", "")) sgnl_url = ""
if isinstance(result, dict):
sgnl_url = result.get("url", result.get("linkUrl", ""))
if not sgnl_url: if not sgnl_url:
print("❌ No sgnl:// URL returned. API response:", file=sys.stderr) print("❌ No sgnl:// URL returned. API response:", file=sys.stderr)
print(json.dumps(result, indent=2), file=sys.stderr) print(json.dumps(result, indent=2), file=sys.stderr)
sys.exit(1) sys.exit(1)
print(f"🔗 Scan this QR code or visit this URL to link your device:") print("🔗 Scan this QR code or visit this URL to link your device:")
print(f" {sgnl_url}") print(f" {sgnl_url}")
print() print()
print("Waiting for confirmation (Ctrl+C to cancel)...") print("Waiting for confirmation (Ctrl+C to cancel)...")
@ -515,7 +624,7 @@ def cmd_link_signal(args):
print(f"✅ Device linked! Account: {result}") print(f"✅ Device linked! Account: {result}")
return return
print("⚠️ Linking timed out. Please try again.", file=sys.stderr) print("⚠️ Linking timed out. Please try again.", file=sys.stderr)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -530,8 +639,8 @@ def main():
"Examples:\n" "Examples:\n"
" %(prog)s --signal --to +18005551212 --text 'Hello!'\n" " %(prog)s --signal --to +18005551212 --text 'Hello!'\n"
" %(prog)s --signal --recipients GROUP_ID --text 'Hello group!'\n" " %(prog)s --signal --recipients GROUP_ID --text 'Hello group!'\n"
" %(prog)s --sms --to +18005551212 --text 'Text via SMS'\n" " %(prog)s --sms --to +18005551212 --text 'Text via SMS'\n"
" %(prog)s --sms --to +18005551212 --text 'Hey' --file ~/pic.jpg\n" " %(prog)s --sms --to +18005551212 --text 'Hey' --file ~/pic.jpg\n"
" %(prog)s --csv messages.csv\n" " %(prog)s --csv messages.csv\n"
" %(prog)s --csv messages.csv --delay 2\n" " %(prog)s --csv messages.csv --delay 2\n"
" %(prog)s --list-signal\n" " %(prog)s --list-signal\n"
@ -551,7 +660,7 @@ def main():
parser.add_argument("-V", "--version", action="version", version=f"%(prog)s {VERSION}") parser.add_argument("-V", "--version", action="version", version=f"%(prog)s {VERSION}")
# Method (mutually exclusive) # Method
method = parser.add_argument_group("Messaging method (choose one)") method = parser.add_argument_group("Messaging method (choose one)")
method.add_argument("--signal", action="store_true", help="Send via Signal") method.add_argument("--signal", action="store_true", help="Send via Signal")
method.add_argument("--sms", action="store_true", help="Send via SMS/iMessage (macOS Messages)") method.add_argument("--sms", action="store_true", help="Send via SMS/iMessage (macOS Messages)")
@ -560,20 +669,22 @@ def main():
# Common options # Common options
parser.add_argument("--to", nargs="+", metavar="PHONE", help="Recipient phone number(s)") parser.add_argument("--to", nargs="+", metavar="PHONE", help="Recipient phone number(s)")
parser.add_argument("--text", nargs="+", metavar="MSG", help="Message text (repeat for multiple)") parser.add_argument("--text", nargs="+", metavar="MSG", help="Message text (repeat for multiple)")
parser.add_argument("--file", "--attach", nargs="+", metavar="PATH", help="Attachment file path(s)") parser.add_argument("--file", "--attach", nargs="+", metavar="PATH", dest="file",
help="Attachment file path(s)")
parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output") parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output")
# Signal-specific # Signal-specific
signal_grp = parser.add_argument_group("Signal options") signal_grp = parser.add_argument_group("Signal options")
signal_grp.add_argument("--account", metavar="PHONE", help="Signal account phone number to use") signal_grp.add_argument("--account", metavar="PHONE", help="Signal account phone number to use")
signal_grp.add_argument("--recipients", metavar="RECIPIENTS", help="Send to Signal group ID (no --to needed)") signal_grp.add_argument("--recipients", metavar="RECIPIENTS", help="Send to Signal group ID (no --to needed)")
signal_grp.add_argument("--voice", action="store_true", help="Send as voice note (Signal only)") signal_grp.add_argument("--name", metavar="NAME", help="Device name for Signal linking (default: 'automation')")
# Management commands # Management commands
mgmt = parser.add_argument_group("Management commands") mgmt = parser.add_argument_group("Management commands")
mgmt.add_argument("--list-signal", action="store_true", help="List linked Signal accounts") mgmt.add_argument("--list-signal", action="store_true", help="List linked Signal accounts")
mgmt.add_argument("--link-signal", action="store_true", help="Link a new device to your Signal account") mgmt.add_argument("--link-signal", action="store_true", help="Link a new device to your Signal account")
signal_grp.add_argument("--name", metavar="NAME", help="Device name for Signal linking (default: 'automation')") mgmt.add_argument("--show-config", action="store_true",
help="Print resolved configuration and where each value came from")
# SMS-specific # SMS-specific
sms_grp = parser.add_argument_group("SMS/iMessage options") sms_grp = parser.add_argument_group("SMS/iMessage options")
@ -585,14 +696,17 @@ def main():
args = parser.parse_args() args = parser.parse_args()
# cmd_signal still reads args.attach in places; keep an alias.
args.attach = args.file
# Validate: must have a method or management command # Validate: must have a method or management command
if not any([args.signal, args.sms, args.csv, args.list_signal, args.link_signal]): if not any([args.signal, args.sms, args.csv, args.list_signal, args.link_signal, args.show_config]):
parser.print_help() parser.print_help()
print("\n❌ Error: Specify --signal, --sms, --csv, --list-signal, or --link-signal", file=sys.stderr) print("\n❌ Error: Specify --signal, --sms, --csv, --list-signal, --link-signal, or --show-config", file=sys.stderr)
sys.exit(1) sys.exit(1)
if sum([args.signal, args.sms]) > 1: if sum([bool(args.signal), bool(args.sms), bool(args.csv)]) > 1:
print("❌ Error: Choose only one method (--signal or --sms).", file=sys.stderr) print("❌ Error: Choose only one method (--signal, --sms, or --csv).", file=sys.stderr)
sys.exit(1) sys.exit(1)
# Set defaults # Set defaults
@ -610,6 +724,8 @@ def main():
cmd_list_signal(args) cmd_list_signal(args)
elif args.link_signal: elif args.link_signal:
cmd_link_signal(args) cmd_link_signal(args)
elif args.show_config:
cmd_show_config(args)
if __name__ == "__main__": if __name__ == "__main__":