mirror of
https://github.com/docwho76/sendmsg.git
synced 2026-08-31 01:41:10 -07:00
Merge pull request #1 from docwho76/first-release
commit first release files and docs
This commit is contained in:
commit
a19513b635
25
.gitignore
vendored
25
.gitignore
vendored
@ -1,3 +1,28 @@
|
|||||||
|
MacOS garbage to ignore
|
||||||
|
# Folder view configuration files
|
||||||
|
.DS_Store
|
||||||
|
.localized
|
||||||
|
|
||||||
|
# Thumbnail cache files
|
||||||
|
._*
|
||||||
|
|
||||||
|
# Files that might appear on external disks / network shares
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
.AppleDB
|
||||||
|
.AppleDesktop
|
||||||
|
Network Trash Folder
|
||||||
|
Temporary Items
|
||||||
|
.apdisk
|
||||||
|
|
||||||
|
# Directory metadata
|
||||||
|
__MACOSX/
|
||||||
|
.AppleDouble
|
||||||
|
.LSOverride
|
||||||
|
|
||||||
|
# Icon files
|
||||||
|
Icon[]
|
||||||
|
|
||||||
# Byte-compiled / optimized / DLL files
|
# Byte-compiled / optimized / DLL files
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[codz]
|
*.py[codz]
|
||||||
|
|||||||
318
README.md
318
README.md
@ -1,2 +1,318 @@
|
|||||||
# sendmsg
|
# sendmsg
|
||||||
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.
|
||||||
|
|
||||||
|
It supports individual direct messages, Signal group broadcasts, file attachments, and bulk sending from CSV files — all with a single, consistent interface.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Architecture](#architecture)
|
||||||
|
- [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)
|
||||||
|
- [CSV Format](#csv-format)
|
||||||
|
- [Configuration](#configuration)
|
||||||
|
- [Examples](#examples)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ sendmsg (CLI) │
|
||||||
|
│ Python 3 script with argparse-based argument parsing │
|
||||||
|
├─────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────┐ ┌──────────────────────────┐ │
|
||||||
|
│ │ --signal │ │ --sms │ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ │ POST to │ │ Call `imsg send` CLI │ │
|
||||||
|
│ │ Signal REST │ │ (macOS Messages) │ │
|
||||||
|
│ │ API │ │ │ │
|
||||||
|
│ └──────┬───────┘ └──────────┬───────────────┘ │
|
||||||
|
│ │ │ │
|
||||||
|
│ ▼ ▼ │
|
||||||
|
│ ┌──────────────────────────────────────────────────┐ │
|
||||||
|
│ │ signal-cli-rest-api (Docker) │ │
|
||||||
|
│ │ http://localhost:8080 (or SIGNAL_REST_URL) │ │
|
||||||
|
│ └──────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Docker Container │ │
|
||||||
|
│ │ signal-cli-rest-api │ │
|
||||||
|
│ └──────────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Signal path:** `sendmsg` → HTTP POST → `signal-cli-rest-api` → Docker container → Signal network.
|
||||||
|
|
||||||
|
**SMS/iMessage path:** `sendmsg` → subprocess call → `imsg` CLI → macOS Messages framework → carrier/Apple.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
### Required
|
||||||
|
|
||||||
|
| Dependency | Version | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| Python 3 | 3.9+ | Script runtime |
|
||||||
|
| macOS 12+ | Monterey or later | Required for iMessage/SMS support |
|
||||||
|
|
||||||
|
### 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 |
|
||||||
|
|
||||||
|
### SMS/iMessage (optional — only needed for `--sms`)
|
||||||
|
|
||||||
|
| Dependency | Version | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `imsg` CLI | latest | macOS Messages / SMS bridge |
|
||||||
|
|
||||||
|
### System
|
||||||
|
|
||||||
|
- **macOS** — The script is designed for macOS; SMS/iMessage uses the native Messages app.
|
||||||
|
- **Network access** — Required to reach the Signal REST API endpoint.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
1. **Place the script:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
**For SMS/iMessage:**
|
||||||
|
```bash
|
||||||
|
# Verify imsg is installed and available
|
||||||
|
imsg send --help
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Test the script:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sendmsg --help
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```
|
||||||
|
sendmsg [OPTIONS]
|
||||||
|
|
||||||
|
Messaging methods (choose one):
|
||||||
|
--signal Send via Signal
|
||||||
|
--sms Send via SMS/iMessage (macOS Messages)
|
||||||
|
--csv FILE Bulk send from a CSV file
|
||||||
|
|
||||||
|
Management commands (choose one):
|
||||||
|
--list-signal List linked Signal accounts
|
||||||
|
--link-signal Link a new device to your Signal account
|
||||||
|
```
|
||||||
|
|
||||||
|
### Signal Messages
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Send a text message
|
||||||
|
sendmsg --signal --to +18885551212 --text "Hello from Signal!"
|
||||||
|
|
||||||
|
# Send to a group
|
||||||
|
sendmsg --signal --recipients group.TestGroupHash --text "Hello everyone!"
|
||||||
|
|
||||||
|
# Send with an attachment
|
||||||
|
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"
|
||||||
|
```
|
||||||
|
|
||||||
|
### SMS / iMessage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Send via iMessage (auto-detected)
|
||||||
|
sendmsg --sms --to +18885551212 --text "Hello via iMessage!"
|
||||||
|
|
||||||
|
# Force SMS service
|
||||||
|
sendmsg --sms --to +18885551212 --text "Hello via SMS!" --service sms
|
||||||
|
|
||||||
|
# Send with an attachment
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
### Management Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List linked Signal accounts
|
||||||
|
sendmsg --list-signal
|
||||||
|
|
||||||
|
# Link a new device
|
||||||
|
sendmsg --link-signal
|
||||||
|
sendmsg --link-signal --name "my-laptop"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CSV Format
|
||||||
|
|
||||||
|
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`) |
|
||||||
|
|
||||||
|
### 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
|
||||||
|
```
|
||||||
|
|
||||||
|
### Status Output
|
||||||
|
|
||||||
|
During a bulk send, each row prints its name (if provided):
|
||||||
|
|
||||||
|
```
|
||||||
|
[1/4] SIGNAL → Alice (+156****1603)
|
||||||
|
[2/4] SIGNAL → Team Alert (group.ZzBHd3NZO...)
|
||||||
|
[3/4] SMS → Bob (+123****7890)
|
||||||
|
[4/4] SIGNAL → Carol (+156****1603)
|
||||||
|
```
|
||||||
|
|
||||||
|
If no `name` is provided, the recipient is shown instead.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Load settings from sendmsg config file in ~/.sendmsg.conf, with env var fallback.
|
||||||
|
|
||||||
|
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 |
|
||||||
|
|
||||||
|
### Override Example
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export SIGNAL_REST_URL=http://localhost:8082
|
||||||
|
export SIGNAL_ACCOUNT=+18885551212
|
||||||
|
sendmsg --csv messages.csv
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### 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!
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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
|
||||||
|
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,,,
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
- **Unknown method:** Rows with invalid `method` values are skipped and reported in the summary.
|
||||||
|
- **Empty message:** Rows without a `message` are skipped and reported.
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary Output
|
||||||
|
|
||||||
|
After processing all rows, a summary is printed:
|
||||||
|
|
||||||
|
```
|
||||||
|
==================================================
|
||||||
|
📊 CSV Send Summary
|
||||||
|
==================================================
|
||||||
|
Total rows: 4
|
||||||
|
✅ Success: 3
|
||||||
|
❌ Failed: 1
|
||||||
|
|
||||||
|
Errors:
|
||||||
|
• Row 2: failed to send
|
||||||
|
==================================================
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*For support or issues, see https://xkcd.com/627/*
|
||||||
|
|||||||
616
sendmsg
Executable file
616
sendmsg
Executable file
@ -0,0 +1,616 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
|
||||||
|
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
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import configparser
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
VERSION = "4.0.0"
|
||||||
|
CONFIG_PATHS = [
|
||||||
|
os.path.expanduser("~/.sendmsg.conf"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def load_config():
|
||||||
|
"""Load settings from sendmsg config file, with env var fallback.
|
||||||
|
|
||||||
|
Config file format (INI-style):
|
||||||
|
[settings]
|
||||||
|
signal_rest_url = http://localhost:8080
|
||||||
|
signal_default_account = +1234567890
|
||||||
|
|
||||||
|
Lookup order: env var > config file > hardcoded default.
|
||||||
|
"""
|
||||||
|
defaults = {
|
||||||
|
"signal_rest_url": "http://localhost:8080",
|
||||||
|
"signal_default_account": "+1234567890",
|
||||||
|
}
|
||||||
|
config = configparser.ConfigParser()
|
||||||
|
|
||||||
|
for path in CONFIG_PATHS:
|
||||||
|
if os.path.isfile(path):
|
||||||
|
config.read(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"])
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Load config at module level for use by all functions
|
||||||
|
CFG = load_config()
|
||||||
|
SIGNAL_REST_URL = CFG["signal_rest_url"]
|
||||||
|
SIGNAL_DEFAULT_ACCOUNT = CFG["signal_default_account"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers — reusable without argparse objects (needed for CSV dispatch)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def send_one_signal(account, recipient, message, group_id=None, attach=None):
|
||||||
|
"""Send a single Signal message. Returns True on success."""
|
||||||
|
payload = {"number": account}
|
||||||
|
|
||||||
|
if group_id:
|
||||||
|
payload["recipients"] = [group_id]
|
||||||
|
else:
|
||||||
|
payload["recipient"] = recipient
|
||||||
|
|
||||||
|
if message:
|
||||||
|
payload["message"] = message
|
||||||
|
|
||||||
|
# Attachments
|
||||||
|
if attach and os.path.isfile(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:
|
||||||
|
result = signal_rest_post("/v2/send", payload=payload)
|
||||||
|
|
||||||
|
return result is not 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])
|
||||||
|
|
||||||
|
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})")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print(f"❌ SMS send failed (exit {result.returncode}): {result.stderr.strip()}", file=sys.stderr)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# REST API
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def signal_rest_post(endpoint, payload=None, files=None):
|
||||||
|
"""Post JSON or multipart form data to the signal-cli-rest-api.
|
||||||
|
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
for fpath in files:
|
||||||
|
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()
|
||||||
|
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")
|
||||||
|
|
||||||
|
body_parts.append(f"--{boundary}--\r\n".encode("utf-8"))
|
||||||
|
full_body = b"".join(body_parts)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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")
|
||||||
|
if resp_data.strip():
|
||||||
|
result = json.loads(resp_data)
|
||||||
|
if isinstance(result, dict) and "error" in result:
|
||||||
|
print(
|
||||||
|
f"❌ Signal REST error: {result['error'].strip()}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
return result
|
||||||
|
return None
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
body = e.read().decode("utf-8", errors="replace")
|
||||||
|
print(
|
||||||
|
f"❌ Signal REST error: HTTP {e.code} — {body[:500]}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
print(f"❌ Cannot reach Signal REST API at {SIGNAL_REST_URL}: {e.reason}", file=sys.stderr)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def signal_rest_get(endpoint):
|
||||||
|
"""GET a JSON response from the signal-cli-rest-api."""
|
||||||
|
url = f"{SIGNAL_REST_URL.rstrip('/')}{endpoint}"
|
||||||
|
req = urllib.request.Request(url, method="GET")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||||
|
data = resp.read().decode("utf-8")
|
||||||
|
if data.strip():
|
||||||
|
return json.loads(data)
|
||||||
|
return None
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
body = e.read().decode("utf-8", errors="replace")
|
||||||
|
print(f"❌ Signal REST error: HTTP {e.code} — {body[:500]}", file=sys.stderr)
|
||||||
|
return None
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
print(f"❌ Cannot reach Signal REST API at {SIGNAL_REST_URL}: {e.reason}", file=sys.stderr)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Command functions
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def cmd_signal(args):
|
||||||
|
"""Send via Signal using the remote REST API."""
|
||||||
|
# --to is only required for direct messages (not group sends)
|
||||||
|
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:
|
||||||
|
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
|
||||||
|
|
||||||
|
# Build the JSON payload for /v2/send
|
||||||
|
payload = {"number": account}
|
||||||
|
|
||||||
|
if args.recipients:
|
||||||
|
# Send to a Signal group — use recipients array with group. prefix
|
||||||
|
payload["recipients"] = [args.recipients]
|
||||||
|
else:
|
||||||
|
# Send to individual recipients (string)
|
||||||
|
payload["recipient"] = 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
|
||||||
|
result = signal_rest_post("/v2/send", payload=payload, files=attach_files)
|
||||||
|
else:
|
||||||
|
# Multiple files: send message first, then each attachment separately
|
||||||
|
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])
|
||||||
|
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)
|
||||||
|
else:
|
||||||
|
# Group message or other case
|
||||||
|
result = signal_rest_post("/v2/send", payload=payload)
|
||||||
|
|
||||||
|
if result is not None:
|
||||||
|
if args.recipients:
|
||||||
|
print(f"✅ Signal message sent to group {args.recipients}")
|
||||||
|
elif args.to:
|
||||||
|
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
|
||||||
|
for key, value in result.items():
|
||||||
|
if key not in ("success",):
|
||||||
|
print(f" {key}: {value}")
|
||||||
|
else:
|
||||||
|
print("❌ Signal message send failed.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_sms(args):
|
||||||
|
"""Send via SMS/iMessage using imsg."""
|
||||||
|
if not args.to:
|
||||||
|
print("❌ Error: --to <phone> is required for SMS.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
if not args.text:
|
||||||
|
print("❌ Error: --text <message> is required for SMS.", file=sys.stderr)
|
||||||
|
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:
|
||||||
|
if not os.path.isfile(f):
|
||||||
|
print(f"⚠️ Warning: file not found: {f}", file=sys.stderr)
|
||||||
|
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})")
|
||||||
|
else:
|
||||||
|
print(f"❌ SMS send failed (exit {result.returncode})", file=sys.stderr)
|
||||||
|
if result.stderr:
|
||||||
|
print(f" {result.stderr.strip()}", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_csv(args):
|
||||||
|
"""Bulk send messages from a CSV file.
|
||||||
|
|
||||||
|
Expected CSV columns:
|
||||||
|
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
|
||||||
|
|
||||||
|
with open(filepath, newline="", encoding="utf-8") as csvfile:
|
||||||
|
reader = csv.DictReader(csvfile)
|
||||||
|
rows = list(reader)
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
print("❌ Error: CSV file is empty (no data rows).", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
total = len(rows)
|
||||||
|
success = 0
|
||||||
|
failed = 0
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
for i, row in enumerate(rows, 1):
|
||||||
|
method = row.get("method", "").strip().lower()
|
||||||
|
message = row.get("message", "").strip()
|
||||||
|
to = row.get("recipient", "").strip()
|
||||||
|
name = row.get("name", "").strip()
|
||||||
|
|
||||||
|
# Skip blank rows
|
||||||
|
if not method and not message:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not method:
|
||||||
|
method = "signal" # default
|
||||||
|
|
||||||
|
if method not in ("signal", "sms"):
|
||||||
|
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)
|
||||||
|
failed += 1
|
||||||
|
errors.append(f"Row {i}: empty message")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Common fields
|
||||||
|
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
|
||||||
|
|
||||||
|
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
|
||||||
|
result = send_one_signal(
|
||||||
|
account=account,
|
||||||
|
recipient=to,
|
||||||
|
message=message,
|
||||||
|
group_id=group_id,
|
||||||
|
attach=file_path,
|
||||||
|
)
|
||||||
|
elif method == "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
|
||||||
|
else:
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
print()
|
||||||
|
print("=" * 50)
|
||||||
|
print("📊 CSV Send Summary")
|
||||||
|
print("=" * 50)
|
||||||
|
print(f" Total rows: {total}")
|
||||||
|
print(f" ✅ Success: {success}")
|
||||||
|
print(f" ❌ Failed: {failed}")
|
||||||
|
if errors:
|
||||||
|
print()
|
||||||
|
print(" Errors:")
|
||||||
|
for e in errors:
|
||||||
|
print(f" • {e}")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
if failed > 0:
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_list_signal(args):
|
||||||
|
"""List linked Signal accounts via the REST API."""
|
||||||
|
result = signal_rest_get("/v1/accounts")
|
||||||
|
if result is None:
|
||||||
|
print(f"❌ Failed to list accounts (cannot reach {SIGNAL_REST_URL})", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if isinstance(result, list):
|
||||||
|
accounts = [str(a) for a in result]
|
||||||
|
elif isinstance(result, dict) and "accounts" in result:
|
||||||
|
accounts = result["accounts"]
|
||||||
|
elif isinstance(result, dict) and "number" in result:
|
||||||
|
accounts = [result["number"]]
|
||||||
|
else:
|
||||||
|
accounts = [str(result)]
|
||||||
|
|
||||||
|
if not accounts:
|
||||||
|
print("No Signal accounts linked. Use --link-signal to set one up.")
|
||||||
|
else:
|
||||||
|
print("Linked Signal accounts:")
|
||||||
|
for acct in accounts:
|
||||||
|
print(f" • {acct}")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_link_signal(args):
|
||||||
|
"""Link a new Signal device using the sgnl:// URL method.
|
||||||
|
|
||||||
|
The signal-cli-rest-api supports device linking via its /v1/link endpoint.
|
||||||
|
This prints the sgnl:// URL to stdout, then waits for the user to scan/confirm.
|
||||||
|
"""
|
||||||
|
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", ""))
|
||||||
|
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(f" {sgnl_url}")
|
||||||
|
print()
|
||||||
|
print("Waiting for confirmation (Ctrl+C to cancel)...")
|
||||||
|
|
||||||
|
start = time.time()
|
||||||
|
while time.time() - start < 120:
|
||||||
|
time.sleep(3)
|
||||||
|
result = signal_rest_get("/v1/accounts")
|
||||||
|
if result and (isinstance(result, list) and len(result) > 0):
|
||||||
|
print(f"✅ Device linked! Account: {result}")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("⚠️ Linking timed out. Please try again.", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Main / argument parsing
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="sendmsg",
|
||||||
|
description="Universal message sender for Signal (REST API) and SMS/iMessage",
|
||||||
|
epilog=(
|
||||||
|
"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 --csv messages.csv\n"
|
||||||
|
" %(prog)s --csv messages.csv --delay 2\n"
|
||||||
|
" %(prog)s --list-signal\n"
|
||||||
|
" %(prog)s --link-signal\n"
|
||||||
|
" %(prog)s --link-signal --name 'my-automation'\n"
|
||||||
|
"\n"
|
||||||
|
"CSV columns: method, recipient, name, message, account, service, file, delay\n"
|
||||||
|
"The 'name' field is printed in the status output during CSV sends.\n"
|
||||||
|
f"Signal REST API: {SIGNAL_REST_URL}\n"
|
||||||
|
"Config file: ~/.sendmsg.conf\n"
|
||||||
|
" [settings]\n"
|
||||||
|
" signal_rest_url = http://localhost:8080\n"
|
||||||
|
" signal_default_account = +1888222333\n"
|
||||||
|
),
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument("-V", "--version", action="version", version=f"%(prog)s {VERSION}")
|
||||||
|
|
||||||
|
# Method (mutually exclusive)
|
||||||
|
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)")
|
||||||
|
method.add_argument("--csv", metavar="FILE", help="Bulk send from a CSV file")
|
||||||
|
|
||||||
|
# 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("-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)")
|
||||||
|
|
||||||
|
# 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')")
|
||||||
|
|
||||||
|
# SMS-specific
|
||||||
|
sms_grp = parser.add_argument_group("SMS/iMessage options")
|
||||||
|
sms_grp.add_argument("--service", choices=["imessage", "sms", "auto"], help="Force service (default: auto-detect)")
|
||||||
|
|
||||||
|
# CSV-specific
|
||||||
|
csv_grp = parser.add_argument_group("CSV options")
|
||||||
|
csv_grp.add_argument("--delay", metavar="SECONDS", help="Wait N seconds between CSV row sends (default: 0)")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Validate: must have a method or management command
|
||||||
|
if not any([args.signal, args.sms, args.csv, args.list_signal, args.link_signal]):
|
||||||
|
parser.print_help()
|
||||||
|
print("\n❌ Error: Specify --signal, --sms, --csv, --list-signal, or --link-signal", 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)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Set defaults
|
||||||
|
if args.link_signal and not args.name:
|
||||||
|
args.name = "automation"
|
||||||
|
|
||||||
|
# Dispatch
|
||||||
|
if args.signal:
|
||||||
|
cmd_signal(args)
|
||||||
|
elif args.sms:
|
||||||
|
cmd_sms(args)
|
||||||
|
elif args.csv:
|
||||||
|
cmd_csv(args)
|
||||||
|
elif args.list_signal:
|
||||||
|
cmd_list_signal(args)
|
||||||
|
elif args.link_signal:
|
||||||
|
cmd_link_signal(args)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Loading…
Reference in New Issue
Block a user