From 9d7476548461f945c4e7115c7c34710053ff68b0 Mon Sep 17 00:00:00 2001 From: Yigid BALABAN Date: Thu, 2 Apr 2026 22:44:23 +0300 Subject: [PATCH] templating, rate-limiting, etc. --- whisper/install.sh | 106 +++++++++++++++++++++++--- whisper/templates/alert | 4 + whisper/templates/deploy | 4 + whisper/templates/report | 5 ++ whisper/whisper | 157 ++++++++++++++++++++++++++++++++++++--- 5 files changed, 253 insertions(+), 23 deletions(-) create mode 100644 whisper/templates/alert create mode 100644 whisper/templates/deploy create mode 100644 whisper/templates/report diff --git a/whisper/install.sh b/whisper/install.sh index 81f4bf4..bf675ac 100755 --- a/whisper/install.sh +++ b/whisper/install.sh @@ -2,10 +2,16 @@ set -e BIN_SRC="./whisper" -BIN_DST="/usr/bin/whisper" -CONF_DIR="/etc/whisper" +BIN_DIR="$HOME/.local/bin" +BIN_DST="$BIN_DIR/whisper" +CONF_DIR="$HOME/.config/whisper" CONF_DST="$CONF_DIR/.env" CONF_SRC="./example.env" +TMPL_SRC_DIR="./templates" +TMPL_DST_DIR="$CONF_DIR/templates" +FISH_BIN="/opt/homebrew/bin/fish" +FISH_FUNC_DIR="$HOME/.config/fish/functions" +FISH_FUNC="$FISH_FUNC_DIR/whisper.fish" die() { printf '%s\n' "$1" >&2 @@ -20,10 +26,45 @@ usage() { exit 0 } +# Append PATH export to an rc file if BIN_DIR isn't already referenced there +patch_rc() { + _rc="$1" + [ -f "$_rc" ] || return 0 + grep -qF "$BIN_DIR" "$_rc" && return 0 + printf '\nexport PATH="%s:$PATH"\n' "$BIN_DIR" >> "$_rc" + printf ' - Added %s to PATH in %s\n' "$BIN_DIR" "$_rc" +} + +ensure_in_path() { + patch_rc "$HOME/.bashrc" + patch_rc "$HOME/.bash_profile" + patch_rc "$HOME/.zshrc" + patch_rc "$HOME/.zprofile" +} + +install_fish_function() { + mkdir -p "$FISH_FUNC_DIR" + printf 'function whisper --description "Send messages via transport (telegram)"\n' > "$FISH_FUNC" + printf ' %s $argv\n' "$BIN_DST" >> "$FISH_FUNC" + printf 'end\n' >> "$FISH_FUNC" + printf ' - Created fish function at %s\n' "$FISH_FUNC" +} + +remove_fish_function() { + if [ -f "$FISH_FUNC" ]; then + rm -f "$FISH_FUNC" + printf ' - Removed %s\n' "$FISH_FUNC" + fi +} + do_install() { printf 'This will:\n' printf ' - Copy whisper to %s\n' "$BIN_DST" - printf ' - Create %s/ with config\n\n' "$CONF_DIR" + printf ' - Create %s/ with config\n' "$CONF_DIR" + printf ' - Patch ~/.bashrc, ~/.bash_profile, ~/.zshrc, ~/.zprofile if needed\n' + [ -x "$FISH_BIN" ] && printf ' - Create fish function at %s\n' "$FISH_FUNC" + printf ' - Seed %s/ with example templates\n' "$TMPL_DST_DIR" + printf '\n' printf 'Proceed? [Y/n] ' read -r answer @@ -34,20 +75,35 @@ do_install() { [ -f "$BIN_SRC" ] || die "Cannot find $BIN_SRC in current directory" [ -f "$CONF_SRC" ] || die "Cannot find $CONF_SRC in current directory" + mkdir -p "$BIN_DIR" 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" + chmod 600 "$CONF_DST" else - printf 'Keeping existing %s\n' "$CONF_DST" + printf ' - Keeping existing %s\n' "$CONF_DST" fi - chown root:root "$CONF_DST" - chmod 644 "$CONF_DST" + mkdir -p "$TMPL_DST_DIR" + if [ -d "$TMPL_SRC_DIR" ]; then + for _tmpl in "$TMPL_SRC_DIR"/*; do + [ -f "$_tmpl" ] || continue + _basename=$(basename "$_tmpl") + if [ ! -f "$TMPL_DST_DIR/$_basename" ]; then + cp "$_tmpl" "$TMPL_DST_DIR/$_basename" + printf ' - Installed template: %s\n' "$_basename" + else + printf ' - Keeping existing template: %s\n' "$_basename" + fi + done + fi + + ensure_in_path + + [ -x "$FISH_BIN" ] && install_fish_function printf '\nInstalled. Edit %s and set your values.\n' "$CONF_DST" } @@ -55,7 +111,9 @@ do_install() { do_uninstall() { printf 'This will remove:\n' printf ' - %s\n' "$BIN_DST" - printf ' - %s/\n\n' "$CONF_DIR" + [ -f "$FISH_FUNC" ] && printf ' - %s\n' "$FISH_FUNC" + [ -d "$CONF_DIR" ] && printf ' - %s/ (env files confirmed individually)\n' "$CONF_DIR" + printf '\n' printf 'Proceed? [y/N] ' read -r answer @@ -65,14 +123,38 @@ do_uninstall() { esac rm -f "$BIN_DST" - rm -rf "$CONF_DIR" + remove_fish_function + + if [ -d "$CONF_DIR" ]; then + for _f in "$CONF_DIR"/.env "$CONF_DIR"/*.env; do + [ -f "$_f" ] || continue + printf 'Delete %s? [y/N] ' "$_f" + read -r _ans + case "$_ans" in + [yY]) rm -f "$_f"; printf ' - Removed %s\n' "$_f" ;; + *) printf ' - Kept %s\n' "$_f" ;; + esac + done + if [ -d "$TMPL_DST_DIR" ]; then + printf 'Delete templates directory %s/? [y/N] ' "$TMPL_DST_DIR" + read -r _ans + case "$_ans" in + [yY]) rm -rf "$TMPL_DST_DIR"; printf ' - Removed %s/\n' "$TMPL_DST_DIR" ;; + *) printf ' - Kept %s/\n' "$TMPL_DST_DIR" ;; + esac + fi + # Remove the directory only if it is now empty + if [ -z "$(ls -A "$CONF_DIR" 2>/dev/null)" ]; then + rmdir "$CONF_DIR" + else + printf ' - Kept %s/ (not empty)\n' "$CONF_DIR" + fi + fi printf 'Done.\n' } # --- main --- -[ "$(id -u)" -eq 0 ] || die "Must be run as root" - ACTION="install" while [ $# -gt 0 ]; do case "$1" in diff --git a/whisper/templates/alert b/whisper/templates/alert new file mode 100644 index 0000000..71a07e8 --- /dev/null +++ b/whisper/templates/alert @@ -0,0 +1,4 @@ +Alert: {1} +Host: {2} + +{3} diff --git a/whisper/templates/deploy b/whisper/templates/deploy new file mode 100644 index 0000000..77ec536 --- /dev/null +++ b/whisper/templates/deploy @@ -0,0 +1,4 @@ +Deployment +Service: {1} +Version: {2} +Status: {3} diff --git a/whisper/templates/report b/whisper/templates/report new file mode 100644 index 0000000..9e08b9a --- /dev/null +++ b/whisper/templates/report @@ -0,0 +1,5 @@ +{1} finished with exit-code {2}. Command: {3} + +{4} +--- +sent by whisper diff --git a/whisper/whisper b/whisper/whisper index e68592d..ed45837 100755 --- a/whisper/whisper +++ b/whisper/whisper @@ -1,5 +1,14 @@ #!/bin/sh # whisper - send messages via transport (telegram) +# +# Exit codes: +# 0 success (message sent, or --help) +# 1 usage error (bad/missing arguments, unknown option, unknown transport) +# 2 dependency error (required external command not found) +# 3 config error (required environment variable not set) +# 4 send error (transport request failed or API rejected the message) +# 5 rate limited (called too soon; retry after RATE_LIMIT_MS ms) +# 6 template error (template not found, missing params, or file read failure) set -e die() { @@ -8,36 +17,148 @@ die() { } 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 'Usage: whisper -m MESSAGE [--via TRANSPORT]\n' + printf ' whisper -t TEMPLATE [-p VALUE ...] [--via TRANSPORT]\n\n' + printf ' -m, --message Message to send\n' + printf ' -t, --template Use a named template from ~/.config/whisper/templates/\n' + printf ' -p Positional params for template ({1}, {2}, ...)\n' + printf ' Prefix with @ to inject file contents\n' + printf ' --via, --transport, -T\n' + printf ' Transport to use (default: telegram)\n' printf ' -h, --help Show this help\n' + printf '\nExamples:\n' + printf ' whisper -m "Server is down"\n' + printf ' whisper -t deploy -p "myapp" "v1.2.3" "success"\n' + printf ' whisper -t alert -p "disk full" "web-01" @/tmp/df-output.txt\n' exit 0 } +# --- template engine --- +TMPL_DIR="$HOME/.config/whisper/templates" + +resolve_template() { + _tmpl_path="$TMPL_DIR/$TEMPLATE" + [ -f "$_tmpl_path" ] || die "template not found: $TEMPLATE (looked in $TMPL_DIR/)" 6 + + _content=$(cat "$_tmpl_path") + + # Resolve @file references in positional params + _i=1 + while [ "$_i" -le "$PARAM_COUNT" ]; do + eval "_val=\$_P${_i}" + case "$_val" in + @*) + _fpath="${_val#@}" + [ -f "$_fpath" ] || die "file not found for param ${_i}: $_fpath" 6 + eval "_P${_i}=\$(cat \"\$_fpath\")" + ;; + esac + _i=$((_i + 1)) + done + + # Substitute {N} placeholders (awk handles special chars safely) + _i=1 + while [ "$_i" -le "$PARAM_COUNT" ]; do + eval "_val=\$_P${_i}" + _placeholder="{${_i}}" + _content=$(printf '%s\n' "$_content" | _WHISPER_REP="$_val" awk \ + -v pat="$_placeholder" \ + 'BEGIN { + rep = ENVIRON["_WHISPER_REP"] + # Escape braces so pat is treated as literal, not regex quantifier + gsub(/\{/, "\\{", pat) + gsub(/\}/, "\\}", pat) + gsub(/\\/, "\\\\", rep) + gsub(/&/, "\\&", rep) + } + { + gsub(pat, rep) + printf "%s%s", sep, $0 + sep = "\n" + }') + _i=$((_i + 1)) + done + + # Check for unfilled placeholders + case "$_content" in + *"{"[0-9]*"}"*) + die "template '$TEMPLATE' has unfilled placeholders (got $PARAM_COUNT param(s))" 6 ;; + esac + + MESSAGE="$_content" +} + # --- 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 +for _envfile in /etc/whisper/.env "$HOME/.config/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 +command -v curl >/dev/null 2>&1 || die "curl is required but not found" 2 + +# --- rate limiter --- +RATE_LIMIT_MS="${RATE_LIMIT_MS:-1000}" +_RATE_STATE_FILE="$HOME/.config/whisper/.last_send" + +_now_ms() { + # GNU date supports %s%3N; macOS date does not (%3N prints literally) + _t=$(date +%s%3N 2>/dev/null) + case "$_t" in + *N) # macOS fallback via python3 + python3 -c "import time; print(int(time.time()*1000))" 2>/dev/null \ + || expr "$(date +%s)" \* 1000 ;; + *) printf '%s\n' "$_t" ;; + esac +} + +_enforce_rate_limit() { + [ "$RATE_LIMIT_MS" -le 0 ] 2>/dev/null && return 0 + if [ -f "$_RATE_STATE_FILE" ]; then + _last=$(cat "$_RATE_STATE_FILE" 2>/dev/null || printf '0') + _now=$(_now_ms) + _elapsed=$(( _now - _last )) + if [ "$_elapsed" -lt "$RATE_LIMIT_MS" ]; then + _remaining=$(( RATE_LIMIT_MS - _elapsed )) + printf 'whisper: rate limited — retry in %s ms\n' "$_remaining" >&2 + exit 5 + fi + fi + mkdir -p "$(dirname "$_RATE_STATE_FILE")" + _now_ms > "$_RATE_STATE_FILE" +} # --- arg parsing --- TRANSPORT="telegram" MESSAGE="" +TEMPLATE="" +PARAM_COUNT=0 while [ $# -gt 0 ]; do case "$1" in -m|--message) [ -n "${2+x}" ] || die "missing value for $1" 1 MESSAGE="$2"; shift 2 ;; - -t|--transport) + -t|--template) + [ -n "${2+x}" ] || die "missing value for $1" 1 + TEMPLATE="$2"; shift 2 ;; + --via|--transport|-T) [ -n "${2+x}" ] || die "missing value for $1" 1 TRANSPORT="$2"; shift 2 ;; + -p) + shift + while [ $# -gt 0 ]; do + case "$1" in + -*) break ;; + *) + PARAM_COUNT=$((PARAM_COUNT + 1)) + eval "_P${PARAM_COUNT}=\$1" + shift ;; + esac + done + ;; -h|--help) usage ;; *) @@ -45,12 +166,20 @@ while [ $# -gt 0 ]; do esac done -[ -n "$MESSAGE" ] || die "message is required (-m)" 1 +if [ -n "$MESSAGE" ] && [ -n "$TEMPLATE" ]; then + die "cannot use both --message and --template" 1 +fi +if [ -z "$MESSAGE" ] && [ -z "$TEMPLATE" ]; then + die "either --message or --template is required" 1 +fi +if [ "$PARAM_COUNT" -gt 0 ] && [ -z "$TEMPLATE" ]; then + die "-p can only be used with --template" 1 +fi # --- 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 + [ -n "$TELEGRAM_BOT_TOKEN" ] || die "TELEGRAM_BOT_TOKEN is not set" 3 + [ -n "$TELEGRAM_CHAT_ID" ] || die "TELEGRAM_CHAT_ID is not set" 3 } # --- transports --- @@ -62,7 +191,7 @@ send_telegram() { response=$(curl -s -X POST "$url" \ -d chat_id="$TELEGRAM_CHAT_ID" \ --data-urlencode text="$MESSAGE" \ - -d parse_mode="HTML") + -d parse_mode="HTML") || die "curl request failed" 4 if echo "$response" | grep -q '"ok":true'; then printf 'whisper: message sent\n' >&2 @@ -71,11 +200,17 @@ send_telegram() { local err err=$(echo "$response" | sed -n 's/.*"description":"\([^"]*\)".*/\1/p') printf 'whisper: send failed: %s\n' "${err:-unknown error}" >&2 - exit 2 + exit 4 fi } +# --- resolve template if used --- +if [ -n "$TEMPLATE" ]; then + resolve_template +fi + # --- dispatch --- +_enforce_rate_limit case "$TRANSPORT" in telegram) send_telegram ;; *) die "unknown transport: $TRANSPORT" 1 ;;