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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user