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