mirror of
https://github.com/docwho76/sendmsg.git
synced 2026-08-31 01:41:10 -07:00
Update docs, fix bugs with group sending and adding attachments
This commit is contained in:
parent
7d99502be4
commit
5a746b5470
217
README.md
217
README.md
@ -2,7 +2,7 @@
|
||||
|
||||
**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.
|
||||
|
||||
@ -14,13 +14,15 @@ It supports individual direct messages, Signal group broadcasts, file attachment
|
||||
- [Dependencies](#dependencies)
|
||||
- [Installation](#installation)
|
||||
- [Usage](#usage)
|
||||
- [Signal Messages](#signal-messages)
|
||||
- [SMS / iMessage](#sms--imessage)
|
||||
- [Bulk Send from CSV](#bulk-send-from-csv)
|
||||
- [Management Commands](#management-commands)
|
||||
* [Signal Messages](#signal-messages)
|
||||
* [SMS / iMessage](#sms--imessage)
|
||||
* [Bulk Send from CSV](#bulk-send-from-csv)
|
||||
* [Management Commands](#management-commands)
|
||||
- [CSV Format](#csv-format)
|
||||
- [Configuration](#configuration)
|
||||
- [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 │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ POST to │ │ Call `imsg send` CLI │ │
|
||||
│ │ Signal REST │ │ │ │
|
||||
│ │ API │ │ │ │
|
||||
│ │ POST JSON │ │ Call `imsg send` CLI │ │
|
||||
│ │ to Signal │ │ │ │
|
||||
│ │ 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.
|
||||
|
||||
@ -62,22 +64,24 @@ It supports individual direct messages, Signal group broadcasts, file attachment
|
||||
### Required
|
||||
|
||||
| Dependency | Version | Purpose |
|
||||
|---|---|---|
|
||||
| ---------- | ----------------- | --------------------------------- |
|
||||
| Python 3 | 3.9+ | Script runtime |
|
||||
| macOS 12+ | Monterey or later | Required for iMessage/SMS support |
|
||||
|
||||
> `sendmsg` uses only the Python standard library — no third-party packages to install.
|
||||
|
||||
### Signal (optional — only needed for `--signal`)
|
||||
|
||||
| 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` (via container) | latest | Signal protocol implementation |
|
||||
|
||||
### SMS/iMessage (optional — only needed for `--sms`)
|
||||
|
||||
| Dependency | Version | Purpose |
|
||||
|---|---|---|
|
||||
| ---------- | ------- | --------------------------- |
|
||||
| `imsg` CLI | latest | macOS Messages / SMS bridge |
|
||||
|
||||
### System
|
||||
@ -91,30 +95,33 @@ It supports individual direct messages, Signal group broadcasts, file attachment
|
||||
|
||||
1. **Place the script:**
|
||||
|
||||
```bash
|
||||
cp sendmsg /usr/local/bin/sendmsg
|
||||
chmod +x /usr/local/bin/sendmsg
|
||||
```
|
||||
```
|
||||
cp sendmsg /usr/local/bin/sendmsg
|
||||
chmod +x /usr/local/bin/sendmsg
|
||||
```
|
||||
|
||||
2. **Ensure dependencies are installed:**
|
||||
|
||||
**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:**
|
||||
```bash
|
||||
# Verify imsg is installed and available
|
||||
imsg send --help
|
||||
```
|
||||
|
||||
```
|
||||
# Verify imsg is installed and available
|
||||
imsg send --help
|
||||
```
|
||||
|
||||
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):
|
||||
--list-signal List linked Signal accounts
|
||||
--link-signal Link a new device to your Signal account
|
||||
--show-config Print resolved configuration and where each value came from
|
||||
```
|
||||
|
||||
### Signal Messages
|
||||
|
||||
```bash
|
||||
```
|
||||
# Send a text message
|
||||
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)
|
||||
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
|
||||
|
||||
```bash
|
||||
```
|
||||
# Send via iMessage (auto-detected)
|
||||
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
|
||||
|
||||
```bash
|
||||
```
|
||||
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
|
||||
|
||||
```bash
|
||||
```
|
||||
# List linked Signal accounts
|
||||
sendmsg --list-signal
|
||||
|
||||
# Link a new device
|
||||
sendmsg --link-signal
|
||||
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
|
||||
```
|
||||
|
||||
---
|
||||
@ -187,24 +206,30 @@ sendmsg --link-signal --name "my-laptop"
|
||||
Create a CSV file with the following columns:
|
||||
|
||||
| Column | Required | Description |
|
||||
|---|---|---|
|
||||
| `method` | Yes | `signal` or `sms` |
|
||||
| `recipient` | Yes | Phone number, group ID (for Signal), or empty for group sends |
|
||||
| ----------- | -------- | ----------------------------------------------------------------------------- |
|
||||
| `method` | Yes | `signal` or `sms` (defaults to `signal` if left blank) |
|
||||
| `recipient` | Yes | Phone number, or a Signal group ID (`group.XXXX` or a raw group key) |
|
||||
| `name` | No | Display name shown during status output (e.g., "Alice", "Marketing Group") |
|
||||
| `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) |
|
||||
| `file` | No | Path to an attachment file (relative or absolute) |
|
||||
| `delay` | No | Seconds to wait after this row is sent (e.g., `3`) |
|
||||
| `file` | No | Path to an attachment file. `~` and environment variables are expanded. |
|
||||
| `delay` | No | Seconds to wait after this row is sent (overrides the global `--delay`). |
|
||||
|
||||
**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
|
||||
|
||||
```csv
|
||||
```
|
||||
method,recipient,name,message,account,service,file,delay
|
||||
signal,+156****1603,Alice,Hello via Signal,+156****1603,,,
|
||||
signal,group.ZzBHd3NZO...,Team Alert,,,,-1
|
||||
sms,+123****7890,Bob,SMS test,,,~/pic.jpg,
|
||||
signal,+156****1603,Carol,With delay,,,-3
|
||||
signal,+18885551111,Alice,Hello via Signal,+18885551111,,,
|
||||
signal,group.ZzBHd3NZO...,Team Alert,Morning update for the team,,,,
|
||||
sms,+18885552222,Bob,SMS test,,,~/pic.jpg,
|
||||
signal,+18885553333,Carol,With a delay after this row,,,,3
|
||||
```
|
||||
|
||||
### Status Output
|
||||
@ -212,10 +237,10 @@ signal,+156****1603,Carol,With delay,,,-3
|
||||
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...)
|
||||
[3/4] SMS → Bob (+123****7890)
|
||||
[4/4] SIGNAL → Carol (+156****1603)
|
||||
[3/4] SMS → Bob (+18885552222)
|
||||
[4/4] SIGNAL → Carol (+18885553333)
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
### 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):
|
||||
[settings]
|
||||
signal_rest_url = http://localhost:8080
|
||||
signal_default_account = +1234567890
|
||||
**Resolution order:** environment variable → config file → built-in default.
|
||||
|
||||
### Config file format (INI-style)
|
||||
|
||||
```
|
||||
[settings]
|
||||
signal_rest_url = http://localhost:8080
|
||||
signal_default_account = +1234567890
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| ----------------- | ----------------------- | ----------------------------------- |
|
||||
| `SIGNAL_REST_URL` | `http://localhost:8080` | URL of the Signal REST API server |
|
||||
| `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
|
||||
|
||||
```bash
|
||||
```
|
||||
export SIGNAL_REST_URL=http://localhost:8082
|
||||
export SIGNAL_ACCOUNT=+18885551212
|
||||
sendmsg --csv messages.csv
|
||||
@ -252,36 +309,42 @@ sendmsg --csv messages.csv
|
||||
|
||||
### Daily Broadcast
|
||||
|
||||
```csv
|
||||
```
|
||||
method,recipient,name,message,account,service,file,delay
|
||||
signal,group.ZzBHd3NZO...,Daily Update,Good morning team! Here's your daily briefing.,+18002222222,,,
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
```csv
|
||||
```
|
||||
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,
|
||||
```
|
||||
|
||||
### 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.
|
||||
- **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.
|
||||
- **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/>*
|
||||
|
||||
356
sendmsg
356
sendmsg
@ -17,15 +17,17 @@ Usage:
|
||||
CSV format (messages.csv):
|
||||
method,recipient,name,message,account,service,file,delay
|
||||
signal,+1888222333,Alice,Hello via Signal,+1888222333,,,
|
||||
signal,group.ZzBHd3NZO...,Group alert,,,,-1
|
||||
sms,+123****7890,Bob,SMS test,,,~/pic.jpg,
|
||||
signal,+1888222333,Carol,With delay,,,-3
|
||||
signal,group.ZzBHd3NZO...,Group alert,Hi team,,,,
|
||||
sms,+1234567890,Bob,SMS test,,,~/pic.jpg,
|
||||
signal,+1888222333,Carol,With delay,,,,3
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import configparser
|
||||
import csv
|
||||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
@ -34,7 +36,8 @@ import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
VERSION = "4.0.0"
|
||||
VERSION = "4.3.0"
|
||||
|
||||
CONFIG_PATHS = [
|
||||
os.path.expanduser("~/.sendmsg.conf"),
|
||||
]
|
||||
@ -54,23 +57,36 @@ def load_config():
|
||||
"signal_rest_url": "http://localhost:8080",
|
||||
"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:
|
||||
if os.path.isfile(path):
|
||||
config.read(path)
|
||||
config_path = path
|
||||
break
|
||||
|
||||
return {
|
||||
"signal_rest_url": (
|
||||
os.environ.get("SIGNAL_REST_URL")
|
||||
or config.get("settings", "signal_rest_url", fallback=defaults["signal_rest_url"])
|
||||
),
|
||||
"signal_default_account": (
|
||||
os.environ.get("SIGNAL_ACCOUNT")
|
||||
or config.get("settings", "signal_default_account", fallback=defaults["signal_default_account"])
|
||||
),
|
||||
}
|
||||
resolved = {}
|
||||
sources = {}
|
||||
for key, default in defaults.items():
|
||||
env_val = os.environ.get(env_vars[key])
|
||||
if env_val:
|
||||
resolved[key] = env_val
|
||||
sources[key] = f"env (${env_vars[key]})"
|
||||
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
|
||||
@ -83,23 +99,87 @@ SIGNAL_DEFAULT_ACCOUNT = CFG["signal_default_account"]
|
||||
# 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):
|
||||
"""Send a single Signal message. Returns True on success."""
|
||||
payload = {"number": account}
|
||||
account = normalize_account(account)
|
||||
|
||||
payload = {"number": account}
|
||||
if group_id:
|
||||
payload["recipients"] = [group_id]
|
||||
else:
|
||||
payload["recipient"] = recipient
|
||||
payload["recipients"] = [recipient]
|
||||
|
||||
if message:
|
||||
payload["message"] = message
|
||||
|
||||
# Attachments
|
||||
if attach and os.path.isfile(attach):
|
||||
# Expand '~' / env vars; CSV supplies literal paths the shell never saw.
|
||||
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])
|
||||
elif message and not group_id:
|
||||
result = signal_rest_post("/v2/send", payload=payload)
|
||||
else:
|
||||
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):
|
||||
"""Send a single SMS/iMessage. Returns True on success."""
|
||||
cmd = ["imsg", "send", "--to", to]
|
||||
|
||||
if service:
|
||||
cmd.extend(["--service", service])
|
||||
|
||||
cmd.extend(["--text", message])
|
||||
|
||||
if file and os.path.isfile(file):
|
||||
if file:
|
||||
file = expand_path(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)
|
||||
|
||||
if result.returncode == 0:
|
||||
service_label = service.upper() if service else "AUTO"
|
||||
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):
|
||||
"""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.
|
||||
"""
|
||||
url = f"{SIGNAL_REST_URL.rstrip('/')}{endpoint}"
|
||||
|
||||
if files is not None and files:
|
||||
# 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"
|
||||
)
|
||||
payload = dict(payload) if payload else {}
|
||||
|
||||
if files:
|
||||
b64_attachments = []
|
||||
for fpath in files:
|
||||
fpath = expand_path(fpath)
|
||||
if not os.path.isfile(fpath):
|
||||
print(f"⚠️ Warning: attachment not found: {fpath}", file=sys.stderr)
|
||||
continue
|
||||
with open(fpath, "rb") as fh:
|
||||
content = fh.read()
|
||||
encoded = base64.b64encode(fh.read()).decode("ascii")
|
||||
fname = os.path.basename(fpath)
|
||||
body_parts.append(
|
||||
f"--{boundary}\r\n"
|
||||
f"Content-Disposition: form-data; name=\"attachment\"; filename=\"{fname}\"\r\n"
|
||||
f"Content-Type: application/octet-stream\r\n\r\n"
|
||||
)
|
||||
body_parts.append(content)
|
||||
body_parts.append(b"\r\n")
|
||||
mime = mimetypes.guess_type(fpath)[0] or "application/octet-stream"
|
||||
# Data-URI form preserves both MIME type and filename.
|
||||
b64_attachments.append(f"data:{mime};filename={fname};base64,{encoded}")
|
||||
|
||||
body_parts.append(f"--{boundary}--\r\n".encode("utf-8"))
|
||||
full_body = b"".join(body_parts)
|
||||
if b64_attachments:
|
||||
payload["base64_attachments"] = b64_attachments
|
||||
|
||||
headers = {
|
||||
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
||||
}
|
||||
else:
|
||||
# JSON payload
|
||||
body = json.dumps(payload).encode("utf-8") if payload else b"{}"
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
headers = {"Content-Type": "application/json"}
|
||||
full_body = body
|
||||
|
||||
req = urllib.request.Request(url, data=full_body, headers=headers, method="POST")
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
resp_data = resp.read().decode("utf-8")
|
||||
@ -194,7 +262,7 @@ def signal_rest_post(endpoint, payload=None, files=None):
|
||||
)
|
||||
return None
|
||||
return result
|
||||
return None
|
||||
return {}
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode("utf-8", errors="replace")
|
||||
print(
|
||||
@ -236,59 +304,58 @@ def cmd_signal(args):
|
||||
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)
|
||||
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)
|
||||
sys.exit(1)
|
||||
|
||||
account = args.account or SIGNAL_DEFAULT_ACCOUNT
|
||||
if not account.startswith("+"):
|
||||
account = "+" + account
|
||||
account = normalize_account(args.account or SIGNAL_DEFAULT_ACCOUNT)
|
||||
|
||||
# Build the JSON payload for /v2/send
|
||||
payload = {"number": account}
|
||||
|
||||
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]
|
||||
else:
|
||||
# Send to individual recipients (string)
|
||||
payload["recipient"] = args.to[0]
|
||||
# Send to an individual recipient. The REST API accepts a
|
||||
# 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)
|
||||
if args.text:
|
||||
payload["message"] = "\n".join(args.text)
|
||||
|
||||
# Attachments
|
||||
attach_files = getattr(args, "attach", None)
|
||||
|
||||
if attach_files and len(attach_files) > 0:
|
||||
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)
|
||||
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", "")}
|
||||
if args.recipients:
|
||||
multi_payload["recipients"] = [args.recipients]
|
||||
else:
|
||||
multi_payload["recipient"] = args.to[0]
|
||||
multi_payload["recipients"] = payload["recipients"]
|
||||
|
||||
# Only send a standalone text message if there is actual text.
|
||||
result = None
|
||||
if multi_payload["message"]:
|
||||
result = signal_rest_post("/v2/send", payload=multi_payload)
|
||||
if result is None:
|
||||
sys.exit(1)
|
||||
# Then send each attachment
|
||||
|
||||
# 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:
|
||||
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)
|
||||
elif args.text and not args.recipients:
|
||||
result = signal_rest_post("/v2/send", payload=payload)
|
||||
else:
|
||||
# Group message or other case
|
||||
result = att_result
|
||||
else:
|
||||
# Text-only send (direct or group).
|
||||
result = signal_rest_post("/v2/send", payload=payload)
|
||||
|
||||
if result is not None:
|
||||
@ -298,8 +365,7 @@ def cmd_signal(args):
|
||||
print(f"✅ Signal message sent to {args.to[0]}")
|
||||
else:
|
||||
print("✅ Signal message sent")
|
||||
if args.verbose and result:
|
||||
# Print any extra details from the API response
|
||||
if args.verbose and isinstance(result, dict):
|
||||
for key, value in result.items():
|
||||
if key not in ("success",):
|
||||
print(f" {key}: {value}")
|
||||
@ -318,21 +384,21 @@ def cmd_sms(args):
|
||||
sys.exit(1)
|
||||
|
||||
cmd = ["imsg", "send", "--to", args.to[0]]
|
||||
|
||||
if args.service:
|
||||
cmd.extend(["--service", args.service])
|
||||
|
||||
msg_text = "\n".join(args.text)
|
||||
cmd.extend(["--text", msg_text])
|
||||
|
||||
if getattr(args, "file", None):
|
||||
for f in args.file:
|
||||
attach_files = getattr(args, "file", None) or getattr(args, "attach", None)
|
||||
if attach_files:
|
||||
for f in attach_files:
|
||||
f = expand_path(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])
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
|
||||
if result.returncode == 0:
|
||||
service_label = args.service.upper() if args.service else "AUTO"
|
||||
print(f"✅ SMS/iMessage sent to {args.to[0]} (service: {service_label})")
|
||||
@ -340,6 +406,7 @@ def cmd_sms(args):
|
||||
print(f"❌ SMS send failed (exit {result.returncode})", file=sys.stderr)
|
||||
if result.stderr:
|
||||
print(f" {result.stderr.strip()}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def cmd_csv(args):
|
||||
@ -349,12 +416,13 @@ def cmd_csv(args):
|
||||
method, recipient, name, message, account, service, file, delay
|
||||
"""
|
||||
filepath = args.csv
|
||||
|
||||
if not os.path.isfile(filepath):
|
||||
print(f"❌ Error: file not found: {filepath}", file=sys.stderr)
|
||||
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:
|
||||
reader = csv.DictReader(csvfile)
|
||||
@ -376,7 +444,7 @@ def cmd_csv(args):
|
||||
name = row.get("name", "").strip()
|
||||
|
||||
# Skip blank rows
|
||||
if not method and not message:
|
||||
if not method and not message and not to:
|
||||
continue
|
||||
|
||||
if not method:
|
||||
@ -398,14 +466,21 @@ def cmd_csv(args):
|
||||
account = row.get("account", "").strip() or SIGNAL_DEFAULT_ACCOUNT
|
||||
service = row.get("service", "").strip() or None
|
||||
file_path = row.get("file", "").strip() or None
|
||||
delay_str = row.get("delay", "0").strip()
|
||||
row_delay = float(delay_str) if delay_str else 0.0
|
||||
delay_str = row.get("delay", "").strip()
|
||||
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)"
|
||||
print(f"[{i}/{total}] {method.upper()} → {label}")
|
||||
|
||||
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(
|
||||
account=account,
|
||||
recipient=to,
|
||||
@ -413,21 +488,13 @@ def cmd_csv(args):
|
||||
group_id=group_id,
|
||||
attach=file_path,
|
||||
)
|
||||
elif method == "sms":
|
||||
else: # sms
|
||||
result = send_one_sms(
|
||||
to=to,
|
||||
message=message,
|
||||
service=service,
|
||||
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:
|
||||
success += 1
|
||||
@ -435,9 +502,10 @@ def cmd_csv(args):
|
||||
failed += 1
|
||||
errors.append(f"Row {i}: failed to send")
|
||||
|
||||
# Delay between sends (only if explicitly set in row)
|
||||
if row_delay > 0:
|
||||
time.sleep(row_delay)
|
||||
# Per-row delay overrides global; otherwise use the global --delay.
|
||||
effective_delay = row_delay if row_delay is not None else global_delay
|
||||
if effective_delay > 0:
|
||||
time.sleep(effective_delay)
|
||||
|
||||
# Summary
|
||||
print()
|
||||
@ -458,6 +526,46 @@ def cmd_csv(args):
|
||||
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):
|
||||
"""List linked Signal accounts via the REST API."""
|
||||
result = signal_rest_get("/v1/accounts")
|
||||
@ -491,18 +599,19 @@ def cmd_link_signal(args):
|
||||
name = args.name or "automation"
|
||||
payload = {"name": name}
|
||||
result = signal_rest_post("/v1/link", payload=payload)
|
||||
|
||||
if result is None:
|
||||
print("❌ Failed to initiate device linking.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
sgnl_url = ""
|
||||
if isinstance(result, dict):
|
||||
sgnl_url = result.get("url", result.get("linkUrl", ""))
|
||||
if not sgnl_url:
|
||||
print("❌ No sgnl:// URL returned. API response:", file=sys.stderr)
|
||||
print(json.dumps(result, indent=2), file=sys.stderr)
|
||||
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()
|
||||
print("Waiting for confirmation (Ctrl+C to cancel)...")
|
||||
@ -551,7 +660,7 @@ def main():
|
||||
|
||||
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.add_argument("--signal", action="store_true", help="Send via Signal")
|
||||
method.add_argument("--sms", action="store_true", help="Send via SMS/iMessage (macOS Messages)")
|
||||
@ -560,20 +669,22 @@ def main():
|
||||
# Common options
|
||||
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("--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")
|
||||
|
||||
# Signal-specific
|
||||
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("--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
|
||||
mgmt = parser.add_argument_group("Management commands")
|
||||
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")
|
||||
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_grp = parser.add_argument_group("SMS/iMessage options")
|
||||
@ -585,14 +696,17 @@ def main():
|
||||
|
||||
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
|
||||
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()
|
||||
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)
|
||||
|
||||
if sum([args.signal, args.sms]) > 1:
|
||||
print("❌ Error: Choose only one method (--signal or --sms).", file=sys.stderr)
|
||||
if sum([bool(args.signal), bool(args.sms), bool(args.csv)]) > 1:
|
||||
print("❌ Error: Choose only one method (--signal, --sms, or --csv).", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Set defaults
|
||||
@ -610,6 +724,8 @@ def main():
|
||||
cmd_list_signal(args)
|
||||
elif args.link_signal:
|
||||
cmd_link_signal(args)
|
||||
elif args.show_config:
|
||||
cmd_show_config(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Loading…
Reference in New Issue
Block a user