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.**
`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.
@ -61,24 +63,26 @@ 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 |
| 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 |
| `signal-cli-rest-api` | latest | Signal REST API server |
| `signal-cli` (via container) | latest | Signal protocol implementation |
| Dependency | Version | Purpose |
| ---------------------------- | ----------------------------- | ------------------------------ |
| 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 |
| 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
```
---
@ -186,25 +205,31 @@ 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 |
| `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`) |
| `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`) |
| Column | Required | Description |
| ----------- | -------- | ----------------------------------------------------------------------------- |
| `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 `$SIGNAL_ACCOUNT` / config value) |
| `service` | No | SMS service: `imessage`, `sms`, or `auto` (SMS only) |
| `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 |
| 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/>*

412
sendmsg
View File

@ -3,29 +3,31 @@
sendmsg — Universal message sender for Signal and SMS/iMessage.
Usage:
sendmsg --signal --to +18005551212 --text "Hello from Signal!"
sendmsg --signal --recipients group_id_here --text "All members"
sendmsg --sms --to +18005551212 --text "Hello via SMS!"
sendmsg --sms --to +18005551212 --text "Hey" --file ~/photo.png
sendmsg --signal --to +18005551212 --text "Multi" --text "more text"
sendmsg --signal --to +18005551212 --attach ~/doc.pdf ~/pic.jpg
sendmsg --csv messages.csv # Bulk send from CSV file
sendmsg --csv messages.csv --delay 2 # Wait N seconds between sends
sendmsg --list-signal # Show linked Signal accounts
sendmsg --link-signal # Link a new device to your existing Signal account
sendmsg --signal --to +18005551212 --text "Hello from Signal!"
sendmsg --signal --recipients group_id_here --text "All members"
sendmsg --sms --to +18005551212 --text "Hello via SMS!"
sendmsg --sms --to +18005551212 --text "Hey" --file ~/photo.png
sendmsg --signal --to +18005551212 --text "Multi" --text "more text"
sendmsg --signal --to +18005551212 --attach ~/doc.pdf ~/pic.jpg
sendmsg --csv messages.csv # Bulk send from CSV file
sendmsg --csv messages.csv --delay 2 # Wait N seconds between sends
sendmsg --list-signal # Show linked Signal accounts
sendmsg --link-signal # Link a new device to your existing Signal account
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
method,recipient,name,message,account,service,file,delay
signal,+1888222333,Alice,Hello via Signal,+1888222333,,,
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):
cmd.extend(["--file", 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)
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"{}"
headers = {"Content-Type": "application/json"}
full_body = body
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]
result = signal_rest_post("/v2/send", payload=multi_payload)
if result is None:
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])
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:
print(f"⚠️ Attachment failed: {fpath}", file=sys.stderr)
elif args.text and not args.recipients:
result = signal_rest_post("/v2/send", payload=payload)
sys.exit(1)
# 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:
# Group message or other case
# 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,21 +406,23 @@ 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):
"""Bulk send messages from a CSV file.
Expected CSV columns:
method, recipient, name, message, account, service, file, delay
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,20 +444,20 @@ 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:
method = "signal" # default
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
errors.append(f"Row {i}: unknown method '{method}'")
continue
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
errors.append(f"Row {i}: empty message")
continue
@ -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 = result.get("url", result.get("linkUrl", ""))
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)...")
@ -515,7 +624,7 @@ def cmd_link_signal(args):
print(f"✅ Device linked! Account: {result}")
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"
" %(prog)s --signal --to +18005551212 --text 'Hello!'\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 'Hey' --file ~/pic.jpg\n"
" %(prog)s --sms --to +18005551212 --text 'Text via SMS'\n"
" %(prog)s --sms --to +18005551212 --text 'Hey' --file ~/pic.jpg\n"
" %(prog)s --csv messages.csv\n"
" %(prog)s --csv messages.csv --delay 2\n"
" %(prog)s --list-signal\n"
@ -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,7 +724,9 @@ 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__":
main()
main()