templating, rate-limiting, etc.

This commit is contained in:
2026-04-02 22:44:23 +03:00
parent a168b4cbea
commit 9d74765484
5 changed files with 253 additions and 23 deletions

View File

@@ -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

4
whisper/templates/alert Normal file
View File

@@ -0,0 +1,4 @@
<b>Alert: {1}</b>
Host: {2}
{3}

4
whisper/templates/deploy Normal file
View File

@@ -0,0 +1,4 @@
<b>Deployment</b>
Service: {1}
Version: {2}
Status: {3}

5
whisper/templates/report Normal file
View File

@@ -0,0 +1,5 @@
{1} finished with exit-code {2}. Command: {3}
{4}
---
sent by whisper

View File

@@ -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 ;;