Tell the operator when a finding needs them

A finding nobody is told about is a finding nobody has, and the card added earlier only shows
them to someone who opens the tab. Only needs_operator is announced — an open finding may still
be repaired by the next pass — one notification for all of them, and each is announced once and
stays quiet until the fault changes or gets worse.

vv_notify() hands the message to common.sh's notify() rather than reimplementing the channels,
and calls detect_hosts() explicitly because load_config.sh deliberately does not: without it the
Unraid notification arrives and Discord silently never does. It also reports false when no
channel is switched on at all, since notify() exits 0 either way and a caller believing that
would mark a finding as told and never mention it again.

Notification text is folded to ASCII. Unraid's notifier dropped an em dash outright and left the
double space behind, which was found by sending one and reading what arrived.
This commit is contained in:
Gmer4Lfe
2026-08-09 21:59:50 -04:00
parent e4423200cc
commit 91357a2d03
4 changed files with 194 additions and 0 deletions
+8
View File
@@ -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,
+11
View File
@@ -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) {
+114
View File
@@ -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;
}
+61
View File
@@ -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<n>_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;
}