#!/bin/bash # Notifications — `libreportal notify ` # --------------------------------------------------------------------------- # 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 <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="$(webuiDir)/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 }