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>
This commit is contained in:
librelad 2026-08-11 21:15:53 +01:00
parent 66c79f997e
commit f221177b12
6 changed files with 300 additions and 0 deletions

View File

@ -0,0 +1,24 @@
# ================================================================================
# Notifications - Alerts when background work fails (updates, backups, installs)
# @icon 🔔
# ================================================================================
CFG_NOTIFY_EVENTS=failures # What to send - Failures only is the quiet default: you hear about a failed update/backup/install and nothing else. All also announces successful task results. [failures:Failures only|all:All task results|off:Off]
CFG_NOTIFY_EMAIL=false # 📧 Email - Send alerts by email. Uses the Mail settings above (CFG_MAIL_*) for the server and sender; enable those first.
CFG_NOTIFY_EMAIL_TO= # Email To - Recipient address for alert emails
CFG_NOTIFY_NTFY=false # 🔔 Ntfy - Send alerts to an ntfy topic (self-hosted or ntfy.sh)
CFG_NOTIFY_NTFY_URL=https://ntfy.sh # Ntfy Server URL - Base URL of the ntfy server
CFG_NOTIFY_NTFY_TOPIC= # Ntfy Topic - Topic name to publish alerts to
CFG_NOTIFY_NTFY_TOKEN= # Ntfy Access Token - Optional bearer token for protected topics
CFG_NOTIFY_GOTIFY=false # 🔔 Gotify - Send alerts to a self-hosted Gotify server
CFG_NOTIFY_GOTIFY_URL= # Gotify Server URL - e.g. https://gotify.example.com
CFG_NOTIFY_GOTIFY_TOKEN= # Gotify App Token - Application token from the Gotify UI
CFG_NOTIFY_DISCORD=false # 💬 Discord - Send alerts to a Discord channel via webhook
CFG_NOTIFY_DISCORD_WEBHOOK= # Discord Webhook URL - https://discord.com/api/webhooks/...
CFG_NOTIFY_SLACK=false # 💬 Slack - Send alerts to a Slack channel via incoming webhook
CFG_NOTIFY_SLACK_WEBHOOK= # Slack Webhook URL - https://hooks.slack.com/services/...
CFG_NOTIFY_TELEGRAM=false # 📱 Telegram - Send alerts via a Telegram bot
CFG_NOTIFY_TELEGRAM_TOKEN= # Telegram Bot Token - From @BotFather, e.g. 123456:ABC-DEF...
CFG_NOTIFY_TELEGRAM_CHAT_ID= # Telegram Chat ID - The chat/channel the bot posts to
CFG_NOTIFY_PUSHOVER=false # 🔔 Pushover - Send alerts via Pushover
CFG_NOTIFY_PUSHOVER_TOKEN= # Pushover App Token - Application API token
CFG_NOTIFY_PUSHOVER_USER= # Pushover User Key - Your user (or group) key

View File

@ -0,0 +1,227 @@
#!/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
}

View File

@ -0,0 +1,20 @@
#!/bin/bash
# Notification Commands Header
# Shows available `libreportal notify` subcommands.
cliShowNotifyHelp()
{
echo ""
echo "Available Notification Commands:"
echo ""
echo " libreportal notify test - Send a test message to every enabled channel"
echo " libreportal notify send <sev> <title> <body> - Send one message now (sev: info|high)"
echo " libreportal notify task <task-id> - Notify about a finished task (used by the task processor)"
echo ""
echo "Channels (email, ntfy, Gotify, Discord, Slack, Telegram, Pushover) are"
echo "configured under Settings → Notifications. The default policy notifies"
echo "on FAILED background tasks only — a failed automatic update or backup"
echo "reaches you without the WebUI being open. Cancelled tasks never notify."
echo ""
}

View File

@ -33,6 +33,8 @@ cli_scripts=(
"cli/commands/instance/cli_instance_header.sh"
"cli/commands/ip/cli_ip_commands.sh"
"cli/commands/ip/cli_ip_header.sh"
"cli/commands/notify/cli_notify_commands.sh"
"cli/commands/notify/cli_notify_header.sh"
"cli/commands/peer/cli_peer_commands.sh"
"cli/commands/peer/cli_peer_header.sh"
"cli/commands/regen/cli_regen_commands.sh"

View File

@ -297,6 +297,7 @@ declare -gA LP_FN_MAP=(
[cliHandleInstallCommands]="cli/commands/install/cli_install_commands.sh"
[cliHandleInstanceCommands]="cli/commands/instance/cli_instance_commands.sh"
[cliHandleIPCommands]="cli/commands/ip/cli_ip_commands.sh"
[cliHandleNotifyCommands]="cli/commands/notify/cli_notify_commands.sh"
[cliHandlePeerCommands]="cli/commands/peer/cli_peer_commands.sh"
[cliHandleRegenCommands]="cli/commands/regen/cli_regen_commands.sh"
[cliHandleResetCommands]="cli/commands/reset/cli_reset_commands.sh"
@ -322,6 +323,7 @@ declare -gA LP_FN_MAP=(
[cliShowInstallHelp]="cli/commands/install/cli_install_header.sh"
[cliShowInstanceHelp]="cli/commands/instance/cli_instance_header.sh"
[cliShowIPHelp]="cli/commands/ip/cli_ip_header.sh"
[cliShowNotifyHelp]="cli/commands/notify/cli_notify_header.sh"
[cliShowPeerHelp]="cli/commands/peer/cli_peer_header.sh"
[cliShowRegenHelp]="cli/commands/regen/cli_regen_header.sh"
[cliShowResetHelp]="cli/commands/reset/cli_reset_header.sh"
@ -631,6 +633,10 @@ declare -gA LP_FN_MAP=(
[_lpJsonEsc]="source/fetch.sh"
[_lpJsonNum]="source/fetch.sh"
[_lpJsonStr]="source/fetch.sh"
[lpNotifyAnyChannelEnabled]="cli/commands/notify/cli_notify_commands.sh"
[_lpNotifyJson]="cli/commands/notify/cli_notify_commands.sh"
[lpNotifySend]="cli/commands/notify/cli_notify_commands.sh"
[lpNotifyTaskResult]="cli/commands/notify/cli_notify_commands.sh"
[lpRegen]="webui/webui_regen.sh"
[lpRegenArrays]="webui/webui_regen.sh"
[_lpRegenStale]="webui/webui_regen.sh"
@ -1311,6 +1317,7 @@ declare -gA LP_FN_ROOT=(
[cliHandleInstallCommands]="scripts"
[cliHandleInstanceCommands]="scripts"
[cliHandleIPCommands]="scripts"
[cliHandleNotifyCommands]="scripts"
[cliHandlePeerCommands]="scripts"
[cliHandleRegenCommands]="scripts"
[cliHandleResetCommands]="scripts"
@ -1336,6 +1343,7 @@ declare -gA LP_FN_ROOT=(
[cliShowInstallHelp]="scripts"
[cliShowInstanceHelp]="scripts"
[cliShowIPHelp]="scripts"
[cliShowNotifyHelp]="scripts"
[cliShowPeerHelp]="scripts"
[cliShowRegenHelp]="scripts"
[cliShowResetHelp]="scripts"
@ -1645,6 +1653,10 @@ declare -gA LP_FN_ROOT=(
[_lpJsonEsc]="scripts"
[_lpJsonNum]="scripts"
[_lpJsonStr]="scripts"
[lpNotifyAnyChannelEnabled]="scripts"
[_lpNotifyJson]="scripts"
[lpNotifySend]="scripts"
[lpNotifyTaskResult]="scripts"
[lpRegen]="scripts"
[lpRegenArrays]="scripts"
[_lpRegenStale]="scripts"
@ -2358,6 +2370,7 @@ cliHandleHelpCommands() { unset -f cliHandleHelpCommands; __lpAutoload "${instal
cliHandleInstallCommands() { unset -f cliHandleInstallCommands; __lpAutoload "${install_scripts_dir}cli/commands/install/cli_install_commands.sh"; cliHandleInstallCommands "$@"; }
cliHandleInstanceCommands() { unset -f cliHandleInstanceCommands; __lpAutoload "${install_scripts_dir}cli/commands/instance/cli_instance_commands.sh"; cliHandleInstanceCommands "$@"; }
cliHandleIPCommands() { unset -f cliHandleIPCommands; __lpAutoload "${install_scripts_dir}cli/commands/ip/cli_ip_commands.sh"; cliHandleIPCommands "$@"; }
cliHandleNotifyCommands() { unset -f cliHandleNotifyCommands; __lpAutoload "${install_scripts_dir}cli/commands/notify/cli_notify_commands.sh"; cliHandleNotifyCommands "$@"; }
cliHandlePeerCommands() { unset -f cliHandlePeerCommands; __lpAutoload "${install_scripts_dir}cli/commands/peer/cli_peer_commands.sh"; cliHandlePeerCommands "$@"; }
cliHandleRegenCommands() { unset -f cliHandleRegenCommands; __lpAutoload "${install_scripts_dir}cli/commands/regen/cli_regen_commands.sh"; cliHandleRegenCommands "$@"; }
cliHandleResetCommands() { unset -f cliHandleResetCommands; __lpAutoload "${install_scripts_dir}cli/commands/reset/cli_reset_commands.sh"; cliHandleResetCommands "$@"; }
@ -2383,6 +2396,7 @@ cliShowHelpHelp() { unset -f cliShowHelpHelp; __lpAutoload "${install_scripts_di
cliShowInstallHelp() { unset -f cliShowInstallHelp; __lpAutoload "${install_scripts_dir}cli/commands/install/cli_install_header.sh"; cliShowInstallHelp "$@"; }
cliShowInstanceHelp() { unset -f cliShowInstanceHelp; __lpAutoload "${install_scripts_dir}cli/commands/instance/cli_instance_header.sh"; cliShowInstanceHelp "$@"; }
cliShowIPHelp() { unset -f cliShowIPHelp; __lpAutoload "${install_scripts_dir}cli/commands/ip/cli_ip_header.sh"; cliShowIPHelp "$@"; }
cliShowNotifyHelp() { unset -f cliShowNotifyHelp; __lpAutoload "${install_scripts_dir}cli/commands/notify/cli_notify_header.sh"; cliShowNotifyHelp "$@"; }
cliShowPeerHelp() { unset -f cliShowPeerHelp; __lpAutoload "${install_scripts_dir}cli/commands/peer/cli_peer_header.sh"; cliShowPeerHelp "$@"; }
cliShowRegenHelp() { unset -f cliShowRegenHelp; __lpAutoload "${install_scripts_dir}cli/commands/regen/cli_regen_header.sh"; cliShowRegenHelp "$@"; }
cliShowResetHelp() { unset -f cliShowResetHelp; __lpAutoload "${install_scripts_dir}cli/commands/reset/cli_reset_header.sh"; cliShowResetHelp "$@"; }
@ -2692,6 +2706,10 @@ lpInstalledFootprintVersion() { unset -f lpInstalledFootprintVersion; __lpAutolo
_lpJsonEsc() { unset -f _lpJsonEsc; __lpAutoload "${install_scripts_dir}source/fetch.sh"; _lpJsonEsc "$@"; }
_lpJsonNum() { unset -f _lpJsonNum; __lpAutoload "${install_scripts_dir}source/fetch.sh"; _lpJsonNum "$@"; }
_lpJsonStr() { unset -f _lpJsonStr; __lpAutoload "${install_scripts_dir}source/fetch.sh"; _lpJsonStr "$@"; }
lpNotifyAnyChannelEnabled() { unset -f lpNotifyAnyChannelEnabled; __lpAutoload "${install_scripts_dir}cli/commands/notify/cli_notify_commands.sh"; lpNotifyAnyChannelEnabled "$@"; }
_lpNotifyJson() { unset -f _lpNotifyJson; __lpAutoload "${install_scripts_dir}cli/commands/notify/cli_notify_commands.sh"; _lpNotifyJson "$@"; }
lpNotifySend() { unset -f lpNotifySend; __lpAutoload "${install_scripts_dir}cli/commands/notify/cli_notify_commands.sh"; lpNotifySend "$@"; }
lpNotifyTaskResult() { unset -f lpNotifyTaskResult; __lpAutoload "${install_scripts_dir}cli/commands/notify/cli_notify_commands.sh"; lpNotifyTaskResult "$@"; }
lpRegen() { unset -f lpRegen; __lpAutoload "${install_scripts_dir}webui/webui_regen.sh"; lpRegen "$@"; }
lpRegenArrays() { unset -f lpRegenArrays; __lpAutoload "${install_scripts_dir}webui/webui_regen.sh"; lpRegenArrays "$@"; }
_lpRegenStale() { unset -f _lpRegenStale; __lpAutoload "${install_scripts_dir}webui/webui_regen.sh"; _lpRegenStale "$@"; }

View File

@ -429,6 +429,15 @@ runTask() {
cancelled) logInfo "Task $taskId cancelled";;
failed) logError "Task $taskId failed (exit $rc)";;
esac
# Outbound notification for the finished task. The POLICY (which events, which
# channels, or nothing at all) lives entirely in `notify task` — the daemon
# just reports every terminal task and moves on. Detached + backgrounded so a
# slow webhook can never block the dispatch loop, and failures of the notify
# itself are deliberately ignored: notifications are never load-bearing.
if command -v libreportal >/dev/null 2>&1; then
( libreportal notify task "$taskId" >/dev/null 2>&1 & ) 2>/dev/null
fi
}
# ============================================================================