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>
228 lines
10 KiB
Bash
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
|
|
}
|