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
|
||||
__pycache__/
|
||||
*.py[codz]
|
||||
|
||||
318
README.md
318
README.md
@ -1,2 +1,318 @@
|
||||
# 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