Compare commits
4 Commits
4448764bca
...
9c308cef90
| Author | SHA1 | Date | |
|---|---|---|---|
|
9c308cef90
|
|||
|
5bb50a842d
|
|||
|
90f87d8f40
|
|||
|
62e2ea74be
|
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,2 +1,4 @@
|
||||
login-mailer.service
|
||||
|
||||
many-rsync/test-area
|
||||
many-rsync/sync.toml
|
||||
|
||||
45
many-rsync/README.md
Normal file
45
many-rsync/README.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# server-toolset
|
||||
|
||||
## many-rsync
|
||||
|
||||
rsync a set of folders to the remote in parallel.
|
||||
|
||||
### config
|
||||
|
||||
use TOML (preferred) or JSON.
|
||||
|
||||
```toml
|
||||
# parallel rsync configuration
|
||||
# remote_folder: full rsync-compatible remote path
|
||||
# don't forget the trailing slash!
|
||||
# /Users/foo/target/
|
||||
# foo@bar:/home/foo/target/
|
||||
remote_folder = ""
|
||||
|
||||
# local_folders: bare folder names (resolved relative to $HOME)
|
||||
local_folders = ["a"]
|
||||
|
||||
# n: max parallel rsync processes (default: 2)
|
||||
n = 2
|
||||
|
||||
# log_level: pick from DEBUG | INFO | WARNING | ERROR | CRITICAL
|
||||
log_level = "INFO"
|
||||
|
||||
# use to pass arguments to the rsync binary running locally
|
||||
# see rsync help/manpage for details
|
||||
[rsync_parameters]
|
||||
# --rsync-path
|
||||
rsync_path = "/usr/bin/rsync"
|
||||
|
||||
# --exclude-from
|
||||
exclude_from = ".rsync-exclude.txt"
|
||||
```
|
||||
|
||||
- `remote_folder`: rsync-compatible full path to the target folder in remote. "remote" here means that it's the target of the operation, and could still reside on the local system.
|
||||
- **do not forget to use the trailing slash!**
|
||||
- `local_folders`: each folder to be copied over
|
||||
- `n`: how many parallel rsync routines to be spawned
|
||||
- `log_level`: self explanatory
|
||||
- `rsync_parameters`: exposes local rsync binary's options
|
||||
- `rsync_path`: specify the rsync to run on remote machine
|
||||
- `exclude_from`: read exclude patterns from FILE
|
||||
24
many-rsync/example-sync.toml
Normal file
24
many-rsync/example-sync.toml
Normal file
@@ -0,0 +1,24 @@
|
||||
# many-rsync configuration
|
||||
# remote_folder: full rsync-compatible remote path
|
||||
# don't forget the trailing slash!
|
||||
# /Users/foo/target/
|
||||
# foo@bar:/home/foo/target/
|
||||
remote_folder = ""
|
||||
|
||||
# local_folders: bare folder names (resolved relative to $HOME)
|
||||
local_folders = []
|
||||
|
||||
# n: max parallel rsync processes (default: 2)
|
||||
n = 2
|
||||
|
||||
# log_level: pick from DEBUG | INFO | WARNING | ERROR | CRITICAL
|
||||
log_level = "INFO"
|
||||
|
||||
# use to pass arguments to the rsync binary running locally
|
||||
# see rsync help/manpage for details
|
||||
[rsync_parameters]
|
||||
# --rsync-path
|
||||
rsync_path = "/usr/bin/rsync"
|
||||
|
||||
# --exclude-from
|
||||
exclude_from = ".rsync-exclude.txt"
|
||||
175
many-rsync/main.py
Normal file
175
many-rsync/main.py
Normal file
@@ -0,0 +1,175 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
many-rsync
|
||||
author Yigid BALABAN <balaban@yigid.dev>
|
||||
co-authored by Opus 4.6
|
||||
|
||||
parallel rsync runner. reads config from TOML (preferred) or JSON.
|
||||
local: foo bar --|many-rsync|--> remote/foo remote/bar
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
import sys
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, TypedDict, Literal
|
||||
|
||||
|
||||
class RsyncParameters(TypedDict, total=False):
|
||||
rsync_path: str
|
||||
exclude_from: str
|
||||
|
||||
|
||||
class Config(TypedDict):
|
||||
local_folders: list[Path]
|
||||
remote_folder: str
|
||||
n: int
|
||||
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
||||
rsync_parameters: RsyncParameters
|
||||
|
||||
|
||||
try:
|
||||
import tomllib # 3.11+
|
||||
except ModuleNotFoundError:
|
||||
tomllib = None
|
||||
|
||||
HOME = Path.home()
|
||||
LOG_DIR = HOME / ".rsync-logs"
|
||||
RSYNC_BASE = ("rsync", "-avh", "--progress", "--delete", "--stats")
|
||||
|
||||
logger = logging.getLogger("sync")
|
||||
|
||||
|
||||
# ── config ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _load_raw(path: Path) -> dict[str, Any]:
|
||||
text = path.read_text()
|
||||
if path.suffix == ".toml":
|
||||
if tomllib is None:
|
||||
sys.exit("FATAL: Python < 3.11 has no tomllib; install tomli or use JSON config")
|
||||
return tomllib.loads(text)
|
||||
if path.suffix == ".json":
|
||||
return json.loads(text)
|
||||
sys.exit(f"FATAL: unsupported config format: {path.suffix}")
|
||||
|
||||
|
||||
def load_config(path: Path) -> Config:
|
||||
raw = _load_raw(path)
|
||||
folders: list[Any] = raw.get("local_folders", [])
|
||||
if not folders:
|
||||
sys.exit("FATAL: local_folders must be a non-empty list")
|
||||
folders = [Path(f).expanduser() for f in folders]
|
||||
|
||||
for f in folders:
|
||||
if not f.is_dir():
|
||||
sys.exit(f"FATAL: local_folders entries must exist and be folders, got: {f!r}")
|
||||
remote = raw.get("remote_folder")
|
||||
if not remote:
|
||||
sys.exit("FATAL: remote_folder is required")
|
||||
remote = str(remote) # keep as str — may be "host:/path", not a local path
|
||||
n = int(raw.get("n", 1))
|
||||
if n < 1:
|
||||
sys.exit("FATAL: n must be >= 1")
|
||||
if n > len(folders):
|
||||
n = len(folders) # no point spawning idle workers
|
||||
|
||||
level = raw.get("log_level", "INFO").upper()
|
||||
if level not in ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"):
|
||||
sys.exit(f"FATAL: invalid log_level: {level!r}")
|
||||
|
||||
rsync_params: RsyncParameters = {}
|
||||
raw_params = raw.get("rsync_parameters", {})
|
||||
if rsync_path := raw_params.get("rsync_path"):
|
||||
rsync_params["rsync_path"] = str(rsync_path)
|
||||
if exclude_from := raw_params.get("exclude_from"):
|
||||
rsync_params["exclude_from"] = str(exclude_from)
|
||||
|
||||
return {
|
||||
"local_folders": folders, "remote_folder": remote,
|
||||
"n": n, "log_level": level, "rsync_parameters": rsync_params,
|
||||
}
|
||||
|
||||
|
||||
# ── sync ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _build_rsync_cmd(params: RsyncParameters) -> tuple[str, ...]:
|
||||
"""Extend RSYNC_BASE with optional flags from config."""
|
||||
extra: list[str] = []
|
||||
if rp := params.get("rsync_path"):
|
||||
extra.append(f"--rsync-path={rp}")
|
||||
if ef := params.get("exclude_from"):
|
||||
extra.append(f"--exclude-from={ef}")
|
||||
return (*RSYNC_BASE, *extra)
|
||||
|
||||
|
||||
def sync_folder(folder: Path, remote: str, ts: str, params: RsyncParameters) -> tuple[Path, int]:
|
||||
"""Run rsync for a single folder. Returns (folder, returncode)."""
|
||||
log_file = LOG_DIR / f"{folder.name}-{ts}.log"
|
||||
cmd: list[str | Path] = [*_build_rsync_cmd(params), f"{folder}", remote]
|
||||
|
||||
logger.info("START %s → %s (log: %s)", folder, remote, log_file)
|
||||
|
||||
with log_file.open("w") as fh:
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
cwd=HOME,
|
||||
stdin=subprocess.DEVNULL, # prevent parallel processes from fighting over terminal input
|
||||
stdout=fh,
|
||||
stderr=subprocess.STDOUT, # interleave; nothing goes silent
|
||||
text=True,
|
||||
)
|
||||
|
||||
if proc.returncode != 0:
|
||||
logger.error("FAIL %s rc=%d — see %s", folder, proc.returncode, log_file)
|
||||
else:
|
||||
logger.info("OK %s", folder)
|
||||
|
||||
return folder, proc.returncode
|
||||
|
||||
|
||||
# ── main ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
def main() -> None:
|
||||
cfg_path = Path(sys.argv[1]) if len(sys.argv) > 1 else HOME / "sync.toml"
|
||||
cfg = load_config(cfg_path)
|
||||
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
|
||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
logging.basicConfig(
|
||||
level=cfg["log_level"],
|
||||
format="%(asctime)s %(levelname)-5s %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
handlers=[
|
||||
logging.StreamHandler(),
|
||||
logging.FileHandler(LOG_DIR / f"rsync-{ts}.log"),
|
||||
]
|
||||
)
|
||||
|
||||
folders, remote, n = cfg["local_folders"], cfg["remote_folder"], cfg["n"]
|
||||
params = cfg["rsync_parameters"]
|
||||
|
||||
logger.debug("got config: %s", cfg)
|
||||
logger.info("log level is set to %s", cfg["log_level"])
|
||||
logger.info("syncing %d folder(s), parallelism=%d", len(folders), n)
|
||||
|
||||
failed: list[tuple[Path, int]] = []
|
||||
with ThreadPoolExecutor(max_workers=n) as pool:
|
||||
futures = {pool.submit(sync_folder, f, remote, ts, params): f for f in folders}
|
||||
for fut in as_completed(futures):
|
||||
folder, rc = fut.result()
|
||||
if rc != 0:
|
||||
failed.append((folder, rc))
|
||||
|
||||
if failed:
|
||||
for f, rc in failed:
|
||||
logger.critical("FAILED: %s (rc=%d)", f, rc)
|
||||
sys.exit(1)
|
||||
|
||||
logger.info("SUCC all folders synced successfully")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -29,6 +29,15 @@ vim /etc/ssh-notify/config.conf # edit in place
|
||||
chmod 600 /etc/ssh-notify/config.conf
|
||||
```
|
||||
|
||||
**Configuration Options:**
|
||||
|
||||
- `EMAIL_RECIPIENT`: Email address to receive notifications
|
||||
- `EMAIL_API_ENDPOINT`: API endpoint for sending emails
|
||||
- `TELEGRAM_BOT_TOKEN`: Your Telegram bot token
|
||||
- `TELEGRAM_CHAT_ID`: Telegram chat ID to receive notifications
|
||||
- `PAM_TRANSPORTS`: Space-separated list of transports for PAM mode (e.g., `"telegram email"`, `"telegram"`, `"email"`)
|
||||
- `LOG_FILE`: Path to log file (default: `/var/log/ssh-notify.log`)
|
||||
|
||||
### 3. PAM configuration
|
||||
|
||||
```sh
|
||||
@@ -54,13 +63,13 @@ The `ssh-notify.logrotate` tells `logrotate` to rotate `/var/log/ssh‑notify.lo
|
||||
|
||||
Feel free to contact me for collaboration on anything!
|
||||
|
||||
Yiğid BALABAN, <[fyb@fybx.dev][llmail]>
|
||||
Yiğid BALABAN, <[hey@yigid.dev][llmail]>
|
||||
|
||||
[My Website][llwebsite] • [X][llx] • [LinkedIn][lllinkedin]
|
||||
|
||||
2024
|
||||
|
||||
[llmail]: mailto:fyb@fybx.dev
|
||||
[llwebsite]: https://fybx.dev
|
||||
[llmail]: mailto:hey@yigid.dev
|
||||
[llwebsite]: https://yigid.dev
|
||||
[llx]: https://x.com/fybalaban
|
||||
[lllinkedin]: https://linkedin.com/in/fybx
|
||||
[lllinkedin]: https://linkedin.com/in/yigid
|
||||
|
||||
@@ -6,5 +6,14 @@ EMAIL_API_ENDPOINT="https://mail-proxy.example.org/api/mail"
|
||||
TELEGRAM_BOT_TOKEN=""
|
||||
TELEGRAM_CHAT_ID=""
|
||||
|
||||
# Transport Configuration
|
||||
# Space-separated list of transports to use when PAM triggers the script
|
||||
# Valid options: email telegram
|
||||
# Examples:
|
||||
# PAM_TRANSPORTS="telegram email" # both
|
||||
# PAM_TRANSPORTS="telegram" # only Telegram
|
||||
# PAM_TRANSPORTS="email" # only Email
|
||||
PAM_TRANSPORTS="telegram email"
|
||||
|
||||
# Log file for the notifier script
|
||||
LOG_FILE="/var/log/ssh-notify.log"
|
||||
@@ -91,9 +91,47 @@ else
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ensure required config variables are set
|
||||
if [[ -z "$EMAIL_RECIPIENT" || -z "$EMAIL_API_ENDPOINT" || -z "$TELEGRAM_BOT_TOKEN" || -z "$TELEGRAM_CHAT_ID" || -z "$LOG_FILE" ]]; then
|
||||
ERR_MSG="ssh-notify Error: One or more required variables are missing in $CONFIG_FILE."
|
||||
# Set default for PAM_TRANSPORTS if not specified
|
||||
if [[ -z "$PAM_TRANSPORTS" ]]; then
|
||||
PAM_TRANSPORTS="telegram email"
|
||||
fi
|
||||
|
||||
# Validate and parse PAM_TRANSPORTS
|
||||
ENABLE_EMAIL=false
|
||||
ENABLE_TELEGRAM=false
|
||||
for transport in $PAM_TRANSPORTS; do
|
||||
case "$transport" in
|
||||
email)
|
||||
ENABLE_EMAIL=true
|
||||
;;
|
||||
telegram)
|
||||
ENABLE_TELEGRAM=true
|
||||
;;
|
||||
*)
|
||||
ERR_MSG="ssh-notify Warning: Unknown transport '$transport' in PAM_TRANSPORTS. Valid options: email, telegram"
|
||||
echo "$ERR_MSG" | systemd-cat -p warning -t 'ssh-notify'
|
||||
echo "$ERR_MSG" >&2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Ensure required config variables are set based on enabled transports
|
||||
if [[ "$ENABLE_EMAIL" == true && ( -z "$EMAIL_RECIPIENT" || -z "$EMAIL_API_ENDPOINT" ) ]]; then
|
||||
ERR_MSG="ssh-notify Error: Email transport enabled but EMAIL_RECIPIENT or EMAIL_API_ENDPOINT missing in $CONFIG_FILE."
|
||||
echo "$ERR_MSG" | systemd-cat -p err -t 'ssh-notify'
|
||||
echo "$ERR_MSG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$ENABLE_TELEGRAM" == true && ( -z "$TELEGRAM_BOT_TOKEN" || -z "$TELEGRAM_CHAT_ID" ) ]]; then
|
||||
ERR_MSG="ssh-notify Error: Telegram transport enabled but TELEGRAM_BOT_TOKEN or TELEGRAM_CHAT_ID missing in $CONFIG_FILE."
|
||||
echo "$ERR_MSG" | systemd-cat -p err -t 'ssh-notify'
|
||||
echo "$ERR_MSG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "$LOG_FILE" ]]; then
|
||||
ERR_MSG="ssh-notify Error: LOG_FILE missing in $CONFIG_FILE."
|
||||
echo "$ERR_MSG" | systemd-cat -p err -t 'ssh-notify'
|
||||
echo "$ERR_MSG" >&2
|
||||
exit 1
|
||||
@@ -162,8 +200,8 @@ send_telegram() {
|
||||
[[ "$TEST_TYPE" == "both" || "$TEST_TYPE" == "email" ]] && send_email
|
||||
[[ "$TEST_TYPE" == "both" || "$TEST_TYPE" == "telegram" ]] && send_telegram
|
||||
else
|
||||
send_email
|
||||
send_telegram
|
||||
[[ "$ENABLE_EMAIL" == true ]] && send_email
|
||||
[[ "$ENABLE_TELEGRAM" == true ]] && send_telegram
|
||||
fi
|
||||
) &
|
||||
|
||||
|
||||
2
whisper/example.env
Normal file
2
whisper/example.env
Normal file
@@ -0,0 +1,2 @@
|
||||
TELEGRAM_BOT_TOKEN=
|
||||
TELEGRAM_CHAT_ID=
|
||||
89
whisper/install.sh
Executable file
89
whisper/install.sh
Executable file
@@ -0,0 +1,89 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
BIN_SRC="./whisper"
|
||||
BIN_DST="/usr/bin/whisper"
|
||||
CONF_DIR="/etc/whisper"
|
||||
CONF_DST="$CONF_DIR/.env"
|
||||
CONF_SRC="./example.env"
|
||||
|
||||
die() {
|
||||
printf '%s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
usage() {
|
||||
printf 'Usage: install.sh [-i|--install] [-u|--uninstall] [-h|--help]\n'
|
||||
printf ' -i, --install Install whisper (default)\n'
|
||||
printf ' -u, --uninstall Remove whisper\n'
|
||||
printf ' -h, --help Show this help\n'
|
||||
exit 0
|
||||
}
|
||||
|
||||
do_install() {
|
||||
printf 'This will:\n'
|
||||
printf ' - Copy whisper to %s\n' "$BIN_DST"
|
||||
printf ' - Create %s/ with config\n\n' "$CONF_DIR"
|
||||
|
||||
printf 'Proceed? [Y/n] '
|
||||
read -r answer
|
||||
case "$answer" in
|
||||
[nN]) printf 'Aborted.\n'; exit 0 ;;
|
||||
esac
|
||||
|
||||
[ -f "$BIN_SRC" ] || die "Cannot find $BIN_SRC in current directory"
|
||||
[ -f "$CONF_SRC" ] || die "Cannot find $CONF_SRC in current directory"
|
||||
|
||||
cp "$BIN_SRC" "$BIN_DST"
|
||||
chown root:root "$BIN_DST"
|
||||
chmod 755 "$BIN_DST"
|
||||
|
||||
mkdir -p "$CONF_DIR"
|
||||
|
||||
if [ ! -f "$CONF_DST" ]; then
|
||||
cp "$CONF_SRC" "$CONF_DST"
|
||||
else
|
||||
printf 'Keeping existing %s\n' "$CONF_DST"
|
||||
fi
|
||||
|
||||
chown root:root "$CONF_DST"
|
||||
chmod 600 "$CONF_DST"
|
||||
|
||||
printf '\nInstalled. Edit %s and set your values.\n' "$CONF_DST"
|
||||
}
|
||||
|
||||
do_uninstall() {
|
||||
printf 'This will remove:\n'
|
||||
printf ' - %s\n' "$BIN_DST"
|
||||
printf ' - %s/\n\n' "$CONF_DIR"
|
||||
|
||||
printf 'Proceed? [y/N] '
|
||||
read -r answer
|
||||
case "$answer" in
|
||||
[yY]) ;;
|
||||
*) printf 'Aborted.\n'; exit 0 ;;
|
||||
esac
|
||||
|
||||
rm -f "$BIN_DST"
|
||||
rm -rf "$CONF_DIR"
|
||||
|
||||
printf 'Done.\n'
|
||||
}
|
||||
|
||||
# --- main ---
|
||||
[ "$(id -u)" -eq 0 ] || die "Must be run as root"
|
||||
|
||||
ACTION="install"
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-i|--install) ACTION="install"; shift ;;
|
||||
-u|--uninstall) ACTION="uninstall"; shift ;;
|
||||
-h|--help) usage ;;
|
||||
*) die "Unknown option: $1" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
case "$ACTION" in
|
||||
install) do_install ;;
|
||||
uninstall) do_uninstall ;;
|
||||
esac
|
||||
16
whisper/test.sh
Executable file
16
whisper/test.sh
Executable file
@@ -0,0 +1,16 @@
|
||||
#!/bin/sh
|
||||
# manual smoke tests for whisper via telegram
|
||||
|
||||
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||
|
||||
echo '--- test 1: plain text ---\n'
|
||||
"$SCRIPT_DIR/whisper" -t telegram -m "Hello from whisper test" || true
|
||||
|
||||
echo '\n--- test 2: HTML formatting ---\n'
|
||||
"$SCRIPT_DIR/whisper" -t telegram -m "<b>bold</b> <i>italic</i> <code>code</code>" || true
|
||||
|
||||
echo '\n--- test 3: multiline ---\n'
|
||||
MSG=$(printf 'Line one\nLine two\nLine three')
|
||||
"$SCRIPT_DIR/whisper" -t telegram -m "$MSG" || true
|
||||
|
||||
echo '\nAll tests done.\n'
|
||||
82
whisper/whisper
Executable file
82
whisper/whisper
Executable file
@@ -0,0 +1,82 @@
|
||||
#!/bin/sh
|
||||
# whisper - send messages via transport (telegram)
|
||||
set -e
|
||||
|
||||
die() {
|
||||
printf 'whisper: %s\n' "$1" >&2
|
||||
exit "${2:-1}"
|
||||
}
|
||||
|
||||
usage() {
|
||||
printf 'Usage: whisper -m MESSAGE [-t TRANSPORT]\n\n'
|
||||
printf ' -m, --message Message to send (required)\n'
|
||||
printf ' -t, --transport Transport to use (default: telegram)\n'
|
||||
printf ' -h, --help Show this help\n'
|
||||
exit 0
|
||||
}
|
||||
|
||||
# --- config loading ---
|
||||
# source .env files if they exist (later file wins, env vars win over both)
|
||||
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||
for _envfile in /etc/whisper/.env "$SCRIPT_DIR/.env"; do
|
||||
[ -f "$_envfile" ] && . "$_envfile"
|
||||
done
|
||||
unset _envfile
|
||||
|
||||
# --- dependency check ---
|
||||
command -v curl >/dev/null 2>&1 || die "curl is required but not found" 1
|
||||
|
||||
# --- arg parsing ---
|
||||
TRANSPORT="telegram"
|
||||
MESSAGE=""
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-m|--message)
|
||||
[ -n "${2+x}" ] || die "missing value for $1" 1
|
||||
MESSAGE="$2"; shift 2 ;;
|
||||
-t|--transport)
|
||||
[ -n "${2+x}" ] || die "missing value for $1" 1
|
||||
TRANSPORT="$2"; shift 2 ;;
|
||||
-h|--help)
|
||||
usage ;;
|
||||
*)
|
||||
die "unknown option: $1" 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[ -n "$MESSAGE" ] || die "message is required (-m)" 1
|
||||
|
||||
# --- env validation ---
|
||||
validate_telegram_env() {
|
||||
[ -n "$TELEGRAM_BOT_TOKEN" ] || die "TELEGRAM_BOT_TOKEN is not set" 1
|
||||
[ -n "$TELEGRAM_CHAT_ID" ] || die "TELEGRAM_CHAT_ID is not set" 1
|
||||
}
|
||||
|
||||
# --- transports ---
|
||||
send_telegram() {
|
||||
validate_telegram_env
|
||||
local url="https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage"
|
||||
|
||||
local response
|
||||
response=$(curl -s -X POST "$url" \
|
||||
-d chat_id="$TELEGRAM_CHAT_ID" \
|
||||
--data-urlencode text="$MESSAGE" \
|
||||
-d parse_mode="HTML")
|
||||
|
||||
if echo "$response" | grep -q '"ok":true'; then
|
||||
printf 'whisper: message sent\n' >&2
|
||||
return 0
|
||||
else
|
||||
local err
|
||||
err=$(echo "$response" | sed -n 's/.*"description":"\([^"]*\)".*/\1/p')
|
||||
printf 'whisper: send failed: %s\n' "${err:-unknown error}" >&2
|
||||
exit 2
|
||||
fi
|
||||
}
|
||||
|
||||
# --- dispatch ---
|
||||
case "$TRANSPORT" in
|
||||
telegram) send_telegram ;;
|
||||
*) die "unknown transport: $TRANSPORT" 1 ;;
|
||||
esac
|
||||
Reference in New Issue
Block a user