diff --git a/Deployment/master.conf.template b/Deployment/master.conf.template index adf7df6..c053aad 100644 --- a/Deployment/master.conf.template +++ b/Deployment/master.conf.template @@ -1796,6 +1796,14 @@ # this is the budget for proving a candidate — kept short because a sweep may try several, and # an address worth switching to answers quickly or is not worth switching to. AI_PROBE_TIMEOUT=4 +# Tell the operator when a finding needs them. Only findings the sweep cannot resolve itself are +# announced, one notification for all of them rather than one each, and each is announced once — +# it stays quiet through every later pass until the fault changes or gets worse. Delivery is +# whatever notify() is set up to use: NOTIFY_UNRAID and the host's Discord webhook. +# +# On by default, unlike the two switches above. Those gate reading and writing, which are things +# to be trusted first. This gates telling someone, which is the reason for having looked. + AI_REPAIR_NOTIFY_ENABLED=true # ━━━ AI Conf Write Access ━━━ # Separate switch from AI_ENABLED, off by default, and an explicit key whitelist. Never paths, diff --git a/Plugin/unraid/Tools/ai_repair_sweep.php b/Plugin/unraid/Tools/ai_repair_sweep.php index 53fbc93..c911242 100644 --- a/Plugin/unraid/Tools/ai_repair_sweep.php +++ b/Plugin/unraid/Tools/ai_repair_sweep.php @@ -89,6 +89,17 @@ try { exit(0); } + // An announcement is worth a line whether or not this pass found anything new — a delivery + // that failed on an earlier pass is retried here, and "we tried to tell you" is exactly the + // thing someone reads this log to check. + $ann = $sum['announced'] ?? []; + if (($ann['count'] ?? 0) > 0) { + rlog(sprintf('%sannounce: %d finding(s) — %s%s', + $dryRun ? 'dry-run: ' : '', $ann['count'], + ($ann['sent'] ?? false) ? 'sent' : ($dryRun ? 'not sent (dry run)' : 'DELIVERY FAILED'), + ' — ' . ($ann['subject'] ?? ''))); + } + // Nothing found and nothing to say. A line every fifteen minutes reporting no news is how a // log stops being read. if ($sum['findings'] === 0) { diff --git a/Plugin/unraid/include/ai_repair.php b/Plugin/unraid/include/ai_repair.php index 258df16..9d88642 100644 --- a/Plugin/unraid/include/ai_repair.php +++ b/Plugin/unraid/include/ai_repair.php @@ -70,6 +70,10 @@ // ═══════════════════════════════════════════════════════════════════════════════════════════════ require_once __DIR__ . '/config.php'; require_once __DIR__ . '/ai.php'; +// For vv_notify() only. A finding that cannot reach the operator is the one thing this file +// cannot do on its own, and reimplementing the channels here would be a second answer to a +// question common.sh already answers. +require_once __DIR__ . '/common.php'; // What a finding can be. The kind decides which repair is even conceivable, so an unknown kind // is refused rather than stored as an untyped row nothing knows how to act on. @@ -303,6 +307,10 @@ function vv_ai_finding_write(array $f): array { // What the key read when the operator acknowledged it. Null unless acked; the ack // expires the moment the live value stops matching this. 'ack_value' => null, + // What this finding looked like when it was last announced. Carried across sightings + // below — a stamp that reset every fifteen minutes would be a notification every fifteen + // minutes, which is how an operator learns to ignore the channel. + 'notified' => null, ]; $p = vv_ai_finding_path($id); @@ -337,6 +345,11 @@ function vv_ai_finding_write(array $f): array { } // Preserve an operator's note over a generated one. if ($rec['note'] === '' && !empty($old['note'])) $rec['note'] = $old['note']; + + // Carried, never recomputed. This record is rebuilt from scratch on every sighting, + // so anything not copied forward here is reset — and a reset announcement stamp + // means this finding is announced again on the next pass, and the one after that. + $rec['notified'] = $old['notified'] ?? null; } } @@ -465,6 +478,101 @@ function vv_ai_findings_prune(): int { return $n; } +// ── Reaching the operator ──────────────────────────────────────────────────────────────────── +// A finding nobody is told about is a finding nobody has. The card on the AI tab shows them, but +// only to someone who opens the tab, and the point of this subsystem is that it works while +// nobody is looking. +// +// Only needs_operator is announced. An open finding may still be repaired by the next pass, and +// announcing one would be telling the operator about a problem that has already been handled by +// the time they read it. acknowledged and dismissed are the operator's own answers and are never +// announced at all. +// +// AI_REPAIR_NOTIFY_ENABLED defaults to true when absent, unlike the two switches above it. Those +// gate reading and writing, which are things to be trusted first; this gates telling someone, +// which is the point of having found anything. It still cannot fire unless AI_REPAIR_ENABLED is +// on, and notify() itself is subject to NOTIFY_UNRAID and the host's webhook. +function vv_ai_notify_enabled(): bool { + if (!vv_ai_repair_enabled()) return false; + return strtolower(trim((string)(vv_conf_vars()['AI_REPAIR_NOTIFY_ENABLED'] ?? 'true'))) !== 'false'; +} + +// What has to change before this finding is worth mentioning twice. +// +// The same pin as an acknowledgement, plus the severity. That means a finding is announced once +// and then stays quiet — through every fifteen-minute pass, however many times it is seen — until +// either the thing it is about changes or it gets worse. "Indexers unavailable: NzbNoob" becoming +// "NzbNoob, Miatrix" is news; the same sentence for the ninth time is not. +function vv_ai_finding_notify_pin(array $f): string { + return vv_ai_finding_ack_pin($f) . '|' . (string)($f['severity'] ?? 'warn'); +} + +// One notification for everything that needs saying, not one per finding. Returns what it did so +// the sweep can log it and the tests can read it without a notification having to be sent. +function vv_ai_findings_announce(bool $dryRun = false): array { + $out = ['sent' => false, 'count' => 0, 'subject' => '', 'message' => '', 'ids' => []]; + if (!vv_ai_notify_enabled()) return $out + ['skipped' => 'AI_REPAIR_NOTIFY_ENABLED is false']; + + // Nothing switched on to receive it. Checked before anything is composed, and reported as a + // configuration state rather than a delivery failure: a failure is retried on the next pass + // and logged loudly, and neither is the right response to "no channel has been set up". + if (!vv_notify_available()) { + return $out + ['skipped' => 'no notification channel — NOTIFY_UNRAID is false and no webhook is set']; + } + + $due = []; + foreach (vv_ai_findings_list(['needs_operator']) as $f) { + if (($f['notified'] ?? null) === vv_ai_finding_notify_pin($f)) continue; + $due[] = $f; + } + if (!$due) return $out; + + // Worst first: if the message is truncated, the part that survives is the part that matters. + usort($due, fn($a, $b) => (($b['severity'] ?? '') === 'error' ? 1 : 0) + <=> (($a['severity'] ?? '') === 'error' ? 1 : 0)); + + $lines = []; + foreach (array_slice($due, 0, 3) as $f) { + // Evidence is a log excerpt and routinely spans lines. Collapsed here rather than left + // for vv_notify() to tidy on the way out: this string is also what gets logged and what + // the sweep summary returns, and only one of those three readers strips newlines. + // Collapsed before truncating, so 90 characters means 90 visible ones. + $ev = trim(preg_replace('/\s+/u', ' ', (string)($f['evidence'] ?? ''))); + $lines[] = sprintf('%s - %s: %s', $f['subject'] ?? '?', $f['ref'] ?? '?', + mb_substr($ev, 0, 90)); + } + if (count($due) > 3) $lines[] = sprintf('and %d more', count($due) - 3); + + $n = count($due); + $anyErr = false; + foreach ($due as $f) if (($f['severity'] ?? '') === 'error') $anyErr = true; + + $out['count'] = $n; + $out['subject'] = sprintf('Varaverk repair - %d finding%s need%s you', $n, $n === 1 ? '' : 's', + $n === 1 ? 's' : ''); + // ' · ' rather than newlines: notify() builds its Discord payload by printf-ing into a JSON + // string literal, and a raw newline there produces a body the webhook rejects. + $out['message'] = implode(' | ', $lines); + $out['ids'] = array_column($due, 'id'); + + if ($dryRun) return $out; + + $out['sent'] = vv_notify($out['message'], $out['subject'], $anyErr ? 'alert' : 'warning'); + + // Stamped only on a delivery that worked. A channel that is down should retry on the next + // pass rather than mark these as told and go quiet about them forever. + if ($out['sent']) { + foreach ($due as $f) { + $rec = vv_ai_finding_get($f['id']); + if ($rec === null) continue; + $rec['notified'] = vv_ai_finding_notify_pin($rec); + $p = vv_ai_finding_path($f['id']); + if ($p !== null) @file_put_contents($p, json_encode($rec, JSON_PRETTY_PRINT)); + } + } + return $out; +} + // What the assistant should raise when a page loads: things that need the operator, newest // first. Repaired findings are deliberately not here — a fix that worked is a log entry, not a // conversation, and opening every session with a list of things that already went right is how @@ -608,6 +716,12 @@ function vv_ai_repair_sweep(bool $dryRun = false): array { // before a marker written at the end. if (!$dryRun) vv_ai_sweep_mark($started); + // Announced after the marker rather than before, and over the whole store rather than only + // what this pass touched. A finding that reached needs_operator two passes ago and was never + // successfully delivered is still owed to the operator, and the pin is what stops that from + // meaning it is announced twice. + $sum['announced'] = vv_ai_findings_announce($dryRun); + return $sum; } diff --git a/Plugin/unraid/include/common.php b/Plugin/unraid/include/common.php index 3e26338..aaf3fa9 100644 --- a/Plugin/unraid/include/common.php +++ b/Plugin/unraid/include/common.php @@ -933,3 +933,64 @@ function vv_transcode_sessions(): array { 'last_ssd_freed' => $lastSsdFreed, ]; } + +// ── Notification ───────────────────────────────────────────────────────────────────────────── +// Hands a message to common.sh's notify(), rather than reimplementing it here. +// +// notify() is the one place that knows this installation's channels — Unraid's native notifier +// via the adapter, and the per-host Discord webhook — and which of them are switched on. A PHP +// copy would be a second answer to "how does this machine reach its operator", and the two would +// drift the first time a channel is added. +// +// detect_hosts() is called explicitly and that is not optional. load_config.sh deliberately does +// not call it — its header says so — and MY_DISCORD_WEBHOOK is set by detect_hosts() from +// HOST_DISCORD_WEBHOOK. Skipping it yields a notification that reaches the Unraid GUI and +// silently never reaches Discord, which is the failure that looks like success. +// +// Single-line messages only. notify() builds its Discord payload with printf into a JSON string +// literal, so a raw newline in the message produces invalid JSON and the webhook rejects it. +// Callers separate with ' · '. +// Is there anywhere for a notification to go? +// +// notify() exits 0 whether or not it did anything — with NOTIFY_UNRAID false and no webhook it +// logs a line and returns success, because from its point of view nothing went wrong. A caller +// that treats that as delivered will mark its work as told and go quiet about it forever, so the +// question "is any channel switched on" is answered here instead, from the same two settings +// notify() itself consults. +function vv_notify_available(): bool { + $conf = vv_conf_vars(); + if (strtolower(trim((string)($conf['NOTIFY_UNRAID'] ?? 'false'))) === 'true') return true; + $hook = strtoupper(vv_detect_host()) . '_DISCORD_WEBHOOK'; + return trim((string)($conf[$hook] ?? '')) !== ''; +} + +function vv_notify(string $message, string $subject, string $severity = 'normal'): bool { + $loader = SCRIPTS_DIR . '/load_config.sh'; + if (!is_file($loader)) return false; + if (!vv_notify_available()) return false; + if (!in_array($severity, ['normal', 'warning', 'alert'], true)) $severity = 'normal'; + + // Newlines are stripped rather than refused: a caller that accidentally includes one should + // still reach the operator, just on one line. + $message = trim(preg_replace('/\s*[\r\n]+\s*/', ' | ', $message)); + if ($message === '') return false; + + // Typographic characters do not survive Unraid's notifier — it dropped an em dash outright + // and left the double space behind it, which was found by sending one and reading what + // arrived. Every string in this codebase is full of them, so they are folded to ASCII here + // rather than asked of each caller. Anything else non-ASCII is left alone: a share name with + // an accent should arrive imperfectly rather than not at all. + $message = strtr($message, ['—' => '-', '–' => '-', '·' => '|', '→' => '->', + '“' => '"', '”' => '"', '‘' => "'", '’' => "'", '…' => '...']); + $subject = strtr($subject, ['—' => '-', '–' => '-', '·' => '|', '→' => '->', + '“' => '"', '”' => '"', '‘' => "'", '’' => "'", '…' => '...']); + + $script = 'source ' . escapeshellarg($loader) . ' >/dev/null 2>&1 || exit 91; ' + . 'detect_hosts >/dev/null 2>&1; ' + . 'notify ' . escapeshellarg($message) . ' ' . escapeshellarg($subject) . ' ' + . escapeshellarg($severity) . ' >/dev/null 2>&1'; + + $out = []; $rc = 0; + exec('bash -c ' . escapeshellarg($script), $out, $rc); + return $rc === 0; +}