LibrePortal/scripts/cli/commands/notify/cli_notify_commands.sh
librelad f221177b12 feat(notify): outbound alerts for failed background tasks
The missing piece of hands-off updates/backups: when a task fails while
nobody has the WebUI open, LibrePortal now says so — email (via the
existing Mail settings), ntfy, Gotify, Discord, Slack, Telegram, or
Pushover, configured under Settings → Notifications.

One hook, everywhere: the task processor reports every terminal task to
`libreportal notify task <id>` (detached, never load-bearing — hard curl
timeouts, failures ignored). The POLICY lives in the notify command, not
the daemon: CFG_NOTIFY_EVENTS = failures (default) | all | off, and
cancelled tasks never notify. Failure copy is task-aware — a failed
update says the app was already rolled back and won't be retried, so the
reader knows the box is safe before opening the WebUI.

`libreportal notify test` sends to every enabled channel with per-channel
results. Verified against a local mock endpoint: all webhook payloads,
JSON escaping (quotes/newlines), the events policy, and fail-fast on
dead endpoints (8ms, exit nonzero).

The v0.1.0 per-app NOTIFY_* field-mapping scaffolding (never wired to a
sender) stays as-is; this global channel is the system it was waiting on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 21:15:53 +01:00

228 lines
10 KiB
Bash

#!/bin/bash
# Notifications — `libreportal notify <sub>`
# ---------------------------------------------------------------------------
# The outbound half of "hands-off background work": when a task the user is
# not watching fails (an automatic update, a scheduled backup), this is what
# tells them, on channels configured in configs/general/general_notifications.
#
# send <severity> <title> <body...> send one message now (severity: info|high)
# task <task-id> notify about a finished task, applying the
# CFG_NOTIFY_EVENTS policy (the task
# processor calls this after every task)
# test send a test message to every enabled channel
#
# Design rules:
# * Fire-and-forget, never load-bearing: a dead webhook must not fail a task
# or block the processor, so every channel send has hard curl timeouts and
# the caller backgrounds the whole invocation.
# * The POLICY lives here, not in the processor: the processor reports every
# terminal task and this command decides (per CFG_NOTIFY_EVENTS) whether
# anyone hears about it. The daemon never needs the notify config.
# * Cancelled tasks never notify — the user did that themselves.
cliHandleNotifyCommands()
{
local sub="$initial_command2"
case "$sub" in
"send")
local sev="$initial_command3"
local title="$initial_command4"
local body="$initial_command5"
if [[ -z "$title" ]]; then isError "Usage: libreportal notify send <info|high> <title> <body>"; return 1; fi
lpNotifySend "${sev:-info}" "$title" "$body"
;;
"task")
lpNotifyTaskResult "$initial_command3"
;;
"test")
isHeader "Sending a test notification"
if ! lpNotifyAnyChannelEnabled; then
isNotice "No notification channels are enabled — turn one on under Settings → Notifications first."
return 1
fi
lpNotifySend "info" "LibrePortal test notification" \
"Notifications are working on $(hostname 2>/dev/null || echo this-host). Sent $(date '+%Y-%m-%d %H:%M')."
;;
*)
cliShowNotifyHelp
;;
esac
}
# True when at least one channel checkbox is on.
lpNotifyAnyChannelEnabled()
{
[[ "$CFG_NOTIFY_EMAIL" == "true" || "$CFG_NOTIFY_NTFY" == "true" \
|| "$CFG_NOTIFY_GOTIFY" == "true" || "$CFG_NOTIFY_DISCORD" == "true" \
|| "$CFG_NOTIFY_SLACK" == "true" || "$CFG_NOTIFY_TELEGRAM" == "true" \
|| "$CFG_NOTIFY_PUSHOVER" == "true" ]]
}
# JSON string escaper for the webhook payloads. jq is used when present (it
# handles every edge); this fallback covers the two characters that break a
# JSON string plus newlines, which is sufficient for our own message text.
_lpNotifyJson()
{
if command -v jq >/dev/null 2>&1; then
jq -rn --arg s "$1" '$s|tojson'
else
local s="${1//\\/\\\\}"; s="${s//\"/\\\"}"; s="${s//$'\n'/\\n}"
printf '"%s"' "$s"
fi
}
# Send one message to every enabled channel. $1 severity (info|high), $2 title,
# $3 body. Reports per-channel results; returns 0 when every enabled channel
# accepted the message (still 0 when none are enabled — nothing to fail).
lpNotifySend()
{
local sev="$1" title="$2" body="$3"
local curl_opts=(-sS --connect-timeout 5 --max-time 8 -o /dev/null -w '%{http_code}')
local failures=0 sent=0
if ! command -v curl >/dev/null 2>&1; then
isError "notify: curl is required to send notifications."
return 1
fi
# One channel = one _try call: runs the curl, classifies the HTTP code,
# logs per-channel. 2xx = delivered; anything else (or curl error) = fail.
_try() {
local name="$1"; shift
local code; code="$(curl "${curl_opts[@]}" "$@" 2>/dev/null)"
if [[ "$code" =~ ^2[0-9][0-9]$ ]]; then
isSuccessful "notify: $name ok"
sent=$((sent + 1))
else
isError "notify: $name failed (HTTP ${code:-none})"
failures=$((failures + 1))
fi
}
if [[ "$CFG_NOTIFY_NTFY" == "true" && -n "$CFG_NOTIFY_NTFY_TOPIC" ]]; then
local ntfy_args=(-H "Title: $title" -H "Priority: $([[ "$sev" == "high" ]] && echo high || echo default)")
[[ -n "$CFG_NOTIFY_NTFY_TOKEN" ]] && ntfy_args+=(-H "Authorization: Bearer $CFG_NOTIFY_NTFY_TOKEN")
_try ntfy "${ntfy_args[@]}" -d "$body" "${CFG_NOTIFY_NTFY_URL%/}/$CFG_NOTIFY_NTFY_TOPIC"
fi
if [[ "$CFG_NOTIFY_GOTIFY" == "true" && -n "$CFG_NOTIFY_GOTIFY_URL" && -n "$CFG_NOTIFY_GOTIFY_TOKEN" ]]; then
_try gotify -F "title=$title" -F "message=$body" \
-F "priority=$([[ "$sev" == "high" ]] && echo 8 || echo 4)" \
"${CFG_NOTIFY_GOTIFY_URL%/}/message?token=$CFG_NOTIFY_GOTIFY_TOKEN"
fi
if [[ "$CFG_NOTIFY_DISCORD" == "true" && -n "$CFG_NOTIFY_DISCORD_WEBHOOK" ]]; then
_try discord -H 'Content-Type: application/json' \
-d "{\"content\": $(_lpNotifyJson "**${title}**"$'\n'"${body}")}" \
"$CFG_NOTIFY_DISCORD_WEBHOOK"
fi
if [[ "$CFG_NOTIFY_SLACK" == "true" && -n "$CFG_NOTIFY_SLACK_WEBHOOK" ]]; then
_try slack -H 'Content-Type: application/json' \
-d "{\"text\": $(_lpNotifyJson "*${title}*"$'\n'"${body}")}" \
"$CFG_NOTIFY_SLACK_WEBHOOK"
fi
if [[ "$CFG_NOTIFY_TELEGRAM" == "true" && -n "$CFG_NOTIFY_TELEGRAM_TOKEN" && -n "$CFG_NOTIFY_TELEGRAM_CHAT_ID" ]]; then
_try telegram --data-urlencode "chat_id=$CFG_NOTIFY_TELEGRAM_CHAT_ID" \
--data-urlencode "text=${title}"$'\n'"${body}" \
"https://api.telegram.org/bot${CFG_NOTIFY_TELEGRAM_TOKEN}/sendMessage"
fi
if [[ "$CFG_NOTIFY_PUSHOVER" == "true" && -n "$CFG_NOTIFY_PUSHOVER_TOKEN" && -n "$CFG_NOTIFY_PUSHOVER_USER" ]]; then
_try pushover -F "token=$CFG_NOTIFY_PUSHOVER_TOKEN" -F "user=$CFG_NOTIFY_PUSHOVER_USER" \
-F "title=$title" -F "message=$body" \
-F "priority=$([[ "$sev" == "high" ]] && echo 1 || echo 0)" \
"https://api.pushover.net/1/messages.json"
fi
# Email rides the existing Mail settings (host/creds/from) — the notify
# config only adds the on/off switch and the recipient. curl speaks SMTP
# natively: smtps:// for implicit TLS (465), --ssl-reqd for STARTTLS (587).
if [[ "$CFG_NOTIFY_EMAIL" == "true" && -n "$CFG_NOTIFY_EMAIL_TO" ]]; then
if [[ "$CFG_MAIL_ENABLED" != "true" ]]; then
isError "notify: email is enabled but Mail settings are off (CFG_MAIL_ENABLED) — skipping."
failures=$((failures + 1))
else
local proto="smtp" mail_args=(--mail-from "$CFG_MAIL_FROM" --mail-rcpt "$CFG_NOTIFY_EMAIL_TO")
case "$CFG_MAIL_SECURE" in
ssl) proto="smtps" ;;
tls) mail_args+=(--ssl-reqd) ;;
esac
[[ -n "$CFG_MAIL_USERNAME" ]] && mail_args+=(--user "$CFG_MAIL_USERNAME:$CFG_MAIL_PASSWORD")
local msg; msg="$(printf 'From: LibrePortal <%s>\r\nTo: %s\r\nSubject: %s\r\n\r\n%s\r\n' \
"$CFG_MAIL_FROM" "$CFG_NOTIFY_EMAIL_TO" "$title" "$body")"
local ecode; ecode="$(printf '%s' "$msg" | curl -sS --connect-timeout 5 --max-time 15 \
-o /dev/null -w '%{http_code}' "${mail_args[@]}" -T - \
"${proto}://${CFG_MAIL_HOST}:${CFG_MAIL_PORT}" 2>/dev/null; echo "rc=$?")"
# SMTP via curl reports no meaningful http_code; trust the exit code.
if [[ "$ecode" == *"rc=0"* ]]; then
isSuccessful "notify: email ok"
sent=$((sent + 1))
else
isError "notify: email failed (${ecode##*rc=})"
failures=$((failures + 1))
fi
fi
fi
if (( sent == 0 && failures == 0 )); then
isNotice "notify: no channels enabled — nothing sent."
return 0
fi
(( failures == 0 ))
}
# Notify about one finished task, applying the events policy. Called by the
# task processor after EVERY terminal task; this is where "should anyone hear
# about it" is decided, so the daemon stays policy-free.
lpNotifyTaskResult()
{
local task_id="$1"
[[ "$task_id" =~ ^task_[0-9]+_[A-Za-z0-9]+$ ]] || return 0
local events="${CFG_NOTIFY_EVENTS:-failures}"
[[ "$events" == "off" ]] && return 0
lpNotifyAnyChannelEnabled || return 0
local f="${containers_dir%/}/libreportal/frontend/data/tasks/${task_id}.json"
[[ -f "$f" ]] || return 0
local status type app exit_code
if command -v jq >/dev/null 2>&1; then
read -r status type app exit_code < <(jq -r '[.status, .type // "task", .app // "", (.exit_code // 0 | tostring)] | join(" ")' "$f" 2>/dev/null)
else
status=$(grep -oE '"status"[[:space:]]*:[[:space:]]*"[^"]+"' "$f" | head -1 | sed 's/.*"\([^"]*\)"$/\1/')
type=$(grep -oE '"type"[[:space:]]*:[[:space:]]*"[^"]+"' "$f" | head -1 | sed 's/.*"\([^"]*\)"$/\1/')
app=$(grep -oE '"app"[[:space:]]*:[[:space:]]*"[^"]+"' "$f" | head -1 | sed 's/.*"\([^"]*\)"$/\1/')
exit_code=""
fi
[[ "$status" == "cancelled" ]] && return 0 # the user did that
[[ "$status" == "failed" || "$events" == "all" ]] || return 0
# Human copy per task type — the failure lines say what ALREADY happened
# (rollback, retained snapshot), so the reader knows the box is safe before
# they even open the WebUI.
local host; host="$(hostname 2>/dev/null || echo LibrePortal)"
local label="${type:-task}"; local extra=""
case "$type" in
updater_apply|updater_apply_all)
label="Update${app:+ of $app}"
extra=" The app was rolled back to its pre-update snapshot and this build will not be retried automatically — retry from the Updates page when ready." ;;
updater_rollback) label="Rollback${app:+ of $app}" ;;
backup) label="Backup${app:+ of $app}"
extra=" Check the Backups page — the previous snapshots are unaffected." ;;
restore) label="Restore${app:+ of $app}" ;;
install) label="Install${app:+ of $app}" ;;
*) label="Task ${type:-?}${app:+ ($app)}" ;;
esac
if [[ "$status" == "failed" ]]; then
lpNotifySend "high" "$host: ${label} failed" \
"${label} failed (exit ${exit_code:-?}) at $(date '+%H:%M').${extra} Full log: WebUI → Tasks → ${task_id}."
else
lpNotifySend "info" "$host: ${label} completed" \
"${label} finished successfully at $(date '+%H:%M')."
fi
}