Files
Varaverk/Plugin/unraid/include/node_chat.php
T

610 lines
30 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// The mesh's message store. A place for the people who run these servers to talk to each
// other — "mine is down for a week", "who has seen this error", "the key you wanted is X" —
// in the one window that already knows which machines are in the partnership.
//
// OPERATIONAL MODEL
// Every node keeps its own copy. Sending is: append here, then push a copy to each recipient
// over the SSH trust the onboard already established. There is no server and no election —
// the store is a log, and a log replicated by append is the shape that survives a partner
// being switched off.
//
// data/db/node_chat/global.jsonl every HOST* in master.conf
// data/db/node_chat/dm-host1-host3.jsonl one pair, names sorted so both sides agree
// data/db/node_chat/.spool/<host>/*.json undelivered, retried by the minute job
//
// DESIGN PRINCIPLES
// Never two hosts.
// Channels are derived from vv_known_hosts(), so a third machine gets a global channel
// that includes it and a DM channel with each existing node, with no code change. The
// pair name is sorted rather than "mine-theirs" precisely so both ends compute the same
// filename without having to agree on who spoke first.
//
// Delete is local, and that is the feature.
// Removing a message everywhere needs tombstones, ordering and a story for a node that
// was offline when the delete happened. Removing it from this machine is one file write.
// The UI says "delete here" so nobody expects otherwise.
//
// Sender identity comes from the transport, not the payload's word for it.
// The record carries `from`, but anything writing to this store already had root over
// SSH — the trust boundary is the key, and a machine that holds it can say anything
// regardless of what this file validates.
//
// OPERATIONAL SAFEGUARDS
// Messages are stored PLAINTEXT on every recipient. Tailscale encrypts the wire; the file on
// disk is readable by anyone with the box. The card says so, because the first thing anyone
// wants to send a partner is a credential.
//
// Channel names are derived, never taken from the caller — a name is rebuilt from known host
// ids, so no request can address a path outside the store.
//
// Retention is per channel and enforced on write, so a busy channel cannot fill the flash.
//
// Delivery failures spool rather than throw. A partner that is asleep is the normal case for
// the message "my server is going down".
//
// EXPORTS
// vv_nc_channels() the channels this host participates in
// vv_nc_read() messages for one channel, newest last
// vv_nc_append() store one record locally
// vv_nc_send() store + deliver to the channel's other members
// vv_nc_delete_local() forget one message on this machine
// vv_nc_flush_spool() retry undelivered
// ═══════════════════════════════════════════════════════════════════════════════════════════════
require_once __DIR__ . '/config.php';
define('VV_NC_DIR', DATA_DIR . '/db/node_chat');
define('VV_NC_SPOOL', VV_NC_DIR . '/.spool');
define('VV_NC_KEEP', 300); // messages retained per channel, per node
function vv_nc_me(): string { return strtolower(vv_detect_host()); }
// One validator, used by both the sender and the receiver.
//
// Both ends must agree on what a style is, and they had two copies of the rules — the sender's
// and the receiver's. Two copies of a whitelist is one that drifts, and the half that drifts is
// the half a partner running newer code writes into. Every value here ends up interpolated into
// a style attribute, so the allowed set is closed rather than sanitised.
function vv_nc_clean_style(array $s): array {
return [
'color' => preg_match('/^#[0-9a-f]{6}$/i', $s['color'] ?? '') ? strtolower($s['color']) : '',
'font' => in_array($s['font'] ?? '', ['mono', 'sans', 'serif'], true) ? $s['font'] : '',
'size' => in_array($s['size'] ?? '', ['sm', 'lg'], true) ? $s['size'] : '',
'bold' => !empty($s['bold']),
'italic' => !empty($s['italic']),
'underline' => !empty($s['underline']),
];
}
// host1/host2/... → the channels this host can see. Global plus one DM per other node.
function vv_nc_channels(): array {
$me = vv_nc_me();
$hosts = array_keys(vv_known_hosts());
$out = [['id' => 'global', 'label' => 'Global', 'members' => $hosts]];
foreach ($hosts as $h) {
if ($h === $me) continue;
$pair = [$me, $h];
sort($pair);
$out[] = ['id' => 'dm-' . implode('-', $pair), 'label' => strtoupper($h), 'members' => $pair];
}
return $out;
}
// Rebuilt from the known channel list rather than trusted — the caller names a channel, and the
// only names that resolve are ones this host is actually a member of.
function vv_nc_channel(string $id): ?array {
foreach (vv_nc_channels() as $c) if ($c['id'] === $id) return $c;
return null;
}
function vv_nc_path(string $channelId): string {
return VV_NC_DIR . '/' . $channelId . '.jsonl';
}
function vv_nc_read(string $channelId, int $limit = 200): array {
$p = vv_nc_path($channelId);
if (!is_file($p)) return [];
$lines = @file($p, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
if (count($lines) > $limit) $lines = array_slice($lines, -$limit);
$out = [];
foreach ($lines as $l) {
$j = json_decode($l, true);
if (is_array($j) && !empty($j['id'])) $out[] = $j;
}
return $out;
}
function vv_nc_append(string $channelId, array $msg): bool {
if (!vv_nc_channel($channelId)) return false;
@mkdir(VV_NC_DIR, 0755, true);
$p = vv_nc_path($channelId);
// Idempotent on id: a spool retry that actually landed the first time must not double-post.
foreach (vv_nc_read($channelId, VV_NC_KEEP) as $m) {
if (($m['id'] ?? '') === ($msg['id'] ?? '')) return true;
}
if (@file_put_contents($p, json_encode($msg, JSON_UNESCAPED_SLASHES) . "\n",
FILE_APPEND | LOCK_EX) === false) return false;
$lines = @file($p, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
if (count($lines) > VV_NC_KEEP) {
$tmp = $p . '.tmp';
if (@file_put_contents($tmp, implode("\n", array_slice($lines, -VV_NC_KEEP)) . "\n") !== false)
@rename($tmp, $p);
}
return true;
}
function vv_nc_delete_local(string $channelId, string $msgId): bool {
$p = vv_nc_path($channelId);
if (!vv_nc_channel($channelId) || !is_file($p)) return false;
$keep = [];
foreach (@file($p, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $l) {
$j = json_decode($l, true);
if (is_array($j) && ($j['id'] ?? '') === $msgId) continue;
$keep[] = $l;
}
$tmp = $p . '.tmp';
if (@file_put_contents($tmp, $keep ? implode("\n", $keep) . "\n" : '') === false) return false;
return @rename($tmp, $p);
}
// Deliver one record to one host. Returns false on any failure so the caller can spool.
function vv_nc_deliver(string $host, string $channelId, array $msg): bool {
$vars = vv_conf_vars();
$me = strtoupper(vv_nc_me());
$sshKey = $vars[$me . '_SSH_KEY'] ?? '';
if (!$sshKey || !is_file($sshKey)) return false;
$hostname = $vars[strtoupper($host)] ?? '';
if ($hostname === '') return false;
$ip = vv_resolve_tailscale_ip($hostname);
if (!$ip) return false;
// The receiving script lives wherever that host installed Varaverk, which is not necessarily
// where this one did — read from the partner's varaverk.cfg, which stays on flash whatever
// layout it uses. Assuming our own path is the bug that broke Step 1b on an appdata mirror.
$sshBase = 'ssh -i ' . escapeshellarg($sshKey)
. ' -o ConnectTimeout=8 -o BatchMode=yes -o StrictHostKeyChecking=no root@' . escapeshellarg($ip);
$cfg = trim((string)shell_exec($sshBase
. ' ' . escapeshellarg('grep -oP \'(?<=SCRIPTS_DIR=")[^"]+\' /boot/config/plugins/varaverk/varaverk.cfg 2>/dev/null')));
$sd = $cfg !== '' ? $cfg : '/boot/config/plugins/varaverk';
$remote = $sd . '/Plugin/unraid/Tools/node_chat_receive.php';
$cmd = $sshBase . ' ' . escapeshellarg('[ -f ' . $remote . ' ] || exit 127; php ' . $remote
. ' --channel=' . escapeshellarg($channelId));
$desc = [0 => ['pipe','r'], 1 => ['pipe','w'], 2 => ['pipe','w']];
$pr = @proc_open($cmd, $desc, $pipes);
if (!is_resource($pr)) return false;
fwrite($pipes[0], json_encode($msg, JSON_UNESCAPED_SLASHES));
fclose($pipes[0]);
stream_get_contents($pipes[1]); fclose($pipes[1]);
stream_get_contents($pipes[2]); fclose($pipes[2]);
return proc_close($pr) === 0;
}
function vv_nc_spool(string $host, string $channelId, array $msg): void {
$dir = VV_NC_SPOOL . '/' . $host;
@mkdir($dir, 0755, true);
@file_put_contents($dir . '/' . $msg['id'] . '.json',
json_encode(['channel' => $channelId, 'msg' => $msg], JSON_UNESCAPED_SLASHES));
}
// Store locally, then deliver to everyone else in the channel. Always returns the record: a
// message that reached no one is still a message this operator wrote and must be able to see.
function vv_nc_send(string $channelId, string $text, array $style = []): ?array {
$ch = vv_nc_channel($channelId);
if (!$ch || trim($text) === '') return null;
$msg = [
'id' => bin2hex(random_bytes(8)),
'ts' => time(),
'from' => vv_nc_me(),
// Reserved from day one so a board or an announcement is a filter later rather than a
// migration. Everything written today is a message.
'kind' => 'msg',
'text' => mb_substr(trim($text), 0, 4000),
'style' => vv_nc_clean_style($style),
];
vv_nc_append($channelId, $msg);
foreach ($ch['members'] as $h) {
if ($h === vv_nc_me()) continue;
if (!vv_nc_deliver($h, $channelId, $msg)) vv_nc_spool($h, $channelId, $msg);
}
return $msg;
}
function vv_nc_flush_spool(): int {
$sent = 0;
foreach (glob(VV_NC_SPOOL . '/*', GLOB_ONLYDIR) ?: [] as $dir) {
$host = basename($dir);
foreach (glob($dir . '/*.json') ?: [] as $f) {
$j = json_decode((string)@file_get_contents($f), true);
if (!is_array($j) || empty($j['msg'])) { @unlink($f); continue; }
if (vv_nc_deliver($host, (string)$j['channel'], $j['msg'])) { @unlink($f); $sent++; }
}
}
return $sent;
}
// ── Card pane + client ────────────────────────────────────────────────────────
//
// Rendered inside the assistant card rather than as a card of its own, so a page carries one
// conversation surface and a switch, not two boxes competing for the same corner of the screen.
// Emitted once per page: there is one mesh store, so a second instance would be two views of the
// same thing fighting over the same element ids.
function vv_nc_pane_markup(string $prefix, bool $meshDefault = false): void {
static $done = false;
if ($done) return;
$done = true;
?>
<div id="<?= htmlspecialchars($prefix, ENT_QUOTES) ?>-pane-mesh" hidden>
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:8px;">
<select id="vv-nc-ch" class="vv-cf-sel" style="max-width:200px;font-size:11px;padding:3px 8px;"
onchange="vvNcSelect(this.value)"></select>
<label class="vv-ai-cb" style="font-size:10px;"
title="Show only what has arrived since you last looked. Unticked shows the whole conversation.">
<input type="checkbox" id="vv-nc-unread" onchange="vvNcRender()"> <span>Unread only</span>
</label>
<label class="vv-ai-cb" style="font-size:10px;"
title="Show the machine's hostname instead of its slot — unRAID-Jayred36 rather than HOST2.">
<input type="checkbox" id="vv-nc-names" onchange="vvNcPref('names');vvNcRender()"> <span>Hostnames</span>
</label>
<label class="vv-ai-cb" style="font-size:10px;"
title="Follow the newest message. Unticks when you scroll up, re-ticks at the bottom.">
<input type="checkbox" id="vv-nc-follow" checked onchange="vvNcPref('follow')"> <span>Auto Scroll</span>
</label>
<!-- Reader's choice, not the sender's. Someone else's purple italic large is their idea of
emphasis, and on a wall-mounted dashboard it is just noise — this turns every message
back into plain text here without changing what they sent or what anyone else sees. -->
<label class="vv-ai-cb" style="font-size:10px;"
title="Render colours, fonts and bold as sent. Unticked shows every message as plain text.">
<input type="checkbox" id="vv-nc-fmt-on" checked onchange="vvNcPref('fmtOn');vvNcRender()"> <span>Formatting</span>
</label>
<span id="vv-nc-status" style="margin-left:auto;font-size:9px;color:#3a3a3a;"></span>
</div>
<div id="vv-nc-log"
style="max-height:260px;overflow-y:auto;padding:6px 2px;font-size:12px;">
<div style="color:#444;font-size:11px;">Loading…</div>
</div>
<div style="display:flex;gap:6px;align-items:flex-start;margin-top:8px;flex-wrap:wrap;">
<textarea id="vv-nc-text" rows="2" placeholder="Message the mesh…"
onkeydown="if((event.ctrlKey||event.metaKey)&&event.key==='Enter')vvNcSend()"
style="flex:1 1 260px;min-width:0;background:#0d0d0d;border:1px solid #2a2a2a;
color:#ddd;border-radius:4px;padding:6px 8px;font-family:inherit;
font-size:12px;resize:vertical;"></textarea>
<div style="display:flex;flex-direction:column;gap:5px;">
<div style="display:flex;gap:5px;align-items:center;">
<!-- One button opens the formatting row, so the common case — type and send — is not
fronted by six controls nobody changes most of the time. -->
<button class="vv-pt-action-btn info" id="vv-nc-fmt-btn" style="font-size:10px;"
title="Text formatting" onclick="vvNcToggleFmt()">Aa ▸</button>
<select id="vv-nc-emoji" class="vv-cf-sel" style="font-size:12px;padding:2px 6px;"
title="Insert an emoji" onchange="vvNcEmoji(this)">
<option value="">🙂</option>
<option value="👍">👍</option><option value="👋">👋</option><option value="✅">✅</option>
<option value="⚠️">⚠️</option><option value="🔥">🔥</option><option value="🎉">🎉</option>
<option value="🛠️">🛠️</option><option value="💾">💾</option><option value="🔑">🔑</option>
<option value="😀">😀</option><option value="😅">😅</option><option value="🤔">🤔</option>
</select>
<button class="vv-pt-action-btn run" style="font-size:11px;" onclick="vvNcSend()">Send</button>
</div>
</div>
</div>
<!-- Formatting row, hidden until asked for. Choices persist per browser so a house style
does not have to be re-picked every message. -->
<div id="vv-nc-fmt" style="display:none;margin-top:6px;gap:5px;flex-wrap:wrap;align-items:center;">
<select id="vv-nc-color" class="vv-cf-sel" style="font-size:10px;padding:3px 6px;" title="Colour"
onchange="vvNcPref('color')">
<option value="">Colour</option>
<option value="#4caf50">Green</option>
<option value="#4a9eff">Blue</option>
<option value="#ff9800">Amber</option>
<option value="#ef5350">Red</option>
<option value="#ab7df6">Violet</option>
<option value="#bdbdbd">Grey</option>
</select>
<select id="vv-nc-font" class="vv-cf-sel" style="font-size:10px;padding:3px 6px;" title="Font"
onchange="vvNcPref('font')">
<option value="">Font</option>
<option value="sans">Sans</option>
<option value="mono">Mono</option>
<option value="serif">Serif</option>
</select>
<select id="vv-nc-size" class="vv-cf-sel" style="font-size:10px;padding:3px 6px;" title="Size"
onchange="vvNcPref('size')">
<option value="">Size</option>
<option value="sm">Small</option>
<option value="lg">Large</option>
</select>
<button class="vv-btn-sm" id="vv-nc-bold" onclick="vvNcTog('bold')"
style="font-weight:700;min-width:26px;">B</button>
<button class="vv-btn-sm" id="vv-nc-italic" onclick="vvNcTog('italic')"
style="font-style:italic;min-width:26px;">I</button>
<button class="vv-btn-sm" id="vv-nc-underline" onclick="vvNcTog('underline')"
style="text-decoration:underline;min-width:26px;">U</button>
<span id="vv-nc-preview" style="margin-left:6px;font-size:11px;color:#444;">preview</span>
</div>
<div style="margin-top:5px;font-size:9px;color:#3a3a3a;line-height:1.5;">
Encrypted over Tailscale between mesh nodes only — but stored as plain text on every
machine that receives it. Delete removes it from this machine, not from theirs.
</div>
</div>
<script>
// ── Mesh chat ─────────────────────────────────────────────────────────────────
// Kept apart from vvPtLoad's 10s poll: that redraws the whole page, and a redraw mid-sentence
// would take the textarea with it.
let _vvNc = { ch: '', me: '', msgs: [], lastRead: 0, chans: [], hosts: {},
fmt: { bold: false, italic: false, underline: false } };
// Formatting and display choices are per browser, not per message — picking a house style once
// and having it forgotten on reload is the same complaint the web-search tick had.
const _VV_NC_PREF = 'vvNcPrefs';
function _vvNcLoadPrefs() {
let p = {};
try { p = JSON.parse(localStorage.getItem(_VV_NC_PREF) || '{}') || {}; } catch (_) {}
for (const k of ['color', 'font', 'size']) {
const el = document.getElementById('vv-nc-' + k);
if (el && p[k] != null) el.value = p[k];
}
for (const k of ['names', 'follow']) {
const id = k === 'fmtOn' ? 'vv-nc-fmt-on' : 'vv-nc-' + k;
const el = document.getElementById(id);
if (el && p[k] != null) el.checked = !!p[k];
}
_vvNc.fmt = Object.assign(_vvNc.fmt, p.fmt || {});
_vvNcPaintFmt();
}
function vvNcPref() {
const p = { fmt: _vvNc.fmt };
for (const k of ['color', 'font', 'size']) p[k] = document.getElementById('vv-nc-' + k)?.value ?? '';
for (const k of ['names', 'follow', 'fmtOn'])
p[k] = !!document.getElementById(k === 'fmtOn' ? 'vv-nc-fmt-on' : 'vv-nc-' + k)?.checked;
try { localStorage.setItem(_VV_NC_PREF, JSON.stringify(p)); } catch (_) {}
_vvNcPaintFmt();
}
function vvNcTog(k) { _vvNc.fmt[k] = !_vvNc.fmt[k]; vvNcPref(); }
// The preview is the only way to see what B/I/U plus a colour actually look like together
// before committing them to a message everyone else keeps a copy of.
function _vvNcPaintFmt() {
for (const k of ['bold', 'italic', 'underline']) {
document.getElementById('vv-nc-' + k)?.classList.toggle('active', !!_vvNc.fmt[k]);
}
const pv = document.getElementById('vv-nc-preview');
if (!pv) return;
const s = _vvNcStyle({ style: {
color: document.getElementById('vv-nc-color')?.value || '',
font: document.getElementById('vv-nc-font')?.value || '',
size: document.getElementById('vv-nc-size')?.value || '',
bold: _vvNc.fmt.bold, italic: _vvNc.fmt.italic, underline: _vvNc.fmt.underline,
}}, false);
pv.setAttribute('style', 'margin-left:6px;' + s);
}
function vvNcToggleFmt() {
const row = document.getElementById('vv-nc-fmt');
const btn = document.getElementById('vv-nc-fmt-btn');
const open = row.style.display !== 'none';
row.style.display = open ? 'none' : 'flex';
btn.textContent = open ? 'Aa ▸' : 'Aa ▾';
}
// Style string for a message. Closed set on both ends — the server validates the same values,
// so nothing here can emit a property the store did not agree to.
function _vvNcStyle(m, mine) {
// Reader wins over sender. Someone else's purple italic large is their idea of emphasis, and
// on a wall-mounted dashboard it is noise — this renders every message plain here without
// changing what they sent or what anyone else sees. The only thing kept is the you/them colour
// distinction, which is orientation rather than decoration.
if (!document.getElementById('vv-nc-fmt-on')?.checked)
return `color:${mine ? '#7a9a7a' : '#9aa'};font-size:12px;`;
const st = m.style || {};
const sizes = { sm: '11px', lg: '14px' };
let s = `color:${st.color || (mine ? '#7a9a7a' : '#9aa')};`;
if (fonts[st.font]) s += `font-family:${fonts[st.font]};`;
s += `font-size:${sizes[st.size] || '12px'};`;
if (st.bold) s += 'font-weight:700;';
if (st.italic) s += 'font-style:italic;';
if (st.underline) s += 'text-decoration:underline;';
return s;
}
// HOST2 or unRAID-Jayred36, depending on the tick. The mapping comes from master.conf via the
// API rather than being reconstructed here.
function _vvNcWho(id) {
const full = document.getElementById('vv-nc-names')?.checked;
return full ? (_vvNc.hosts[id] || id) : id.toUpperCase();
}
function vvNcChans() {
fetch('/plugins/varaverk/api/node_chat.php?_=' + Date.now())
.then(r => r.json())
.then(d => {
if (!d.ok) return;
_vvNc.me = d.me; _vvNc.chans = d.channels || []; _vvNc.hosts = d.hostnames || {};
const sel = document.getElementById('vv-nc-ch');
if (!sel) return;
const keep = _vvNc.ch || (_vvNc.chans[0] || {}).id || '';
sel.innerHTML = _vvNc.chans.map(c =>
`<option value="${vvEscAttr(c.id)}"${c.id === keep ? ' selected' : ''}>`
+ `${vvEscHtml(c.label)}${c.unread ? ' (' + c.unread + ')' : ''}</option>`).join('');
if (!_vvNc.ch) vvNcSelect(keep); else if (_vvNcMeshOn) vvNcLoad();
// Total across every channel: the badge exists so an unread message is visible while the
// card is showing the assistant, which is where it will usually be.
const tot = _vvNc.chans.reduce((n, c) => n + (c.unread || 0), 0);
const b = document.getElementById('vv-nc-badge');
if (b) { b.hidden = !tot || _vvNcMeshOn; b.textContent = '✉ ' + tot; }
})
.catch(() => {});
}
function vvNcSelect(ch) { _vvNc.ch = ch; vvNcLoad(); }
function vvNcLoad() {
if (!_vvNc.ch) return;
fetch('/plugins/varaverk/api/node_chat.php?channel=' + encodeURIComponent(_vvNc.ch) + '&_=' + Date.now())
.then(r => r.json())
.then(d => { if (d.ok) { _vvNc.msgs = d.messages || []; _vvNc.lastRead = d.last_read || 0; vvNcRender(); } })
.catch(() => {});
}
function vvNcRender() {
const box = document.getElementById('vv-nc-log');
if (!box) return;
const unreadOnly = document.getElementById('vv-nc-unread')?.checked;
let msgs = _vvNc.msgs;
if (unreadOnly) msgs = msgs.filter(m => (m.ts || 0) > _vvNc.lastRead && m.from !== _vvNc.me);
if (!msgs.length) {
box.innerHTML = `<div style="color:#3a3a3a;font-size:11px;padding:8px 0;">`
+ (unreadOnly ? 'Nothing new.' : 'No messages yet — say hello.') + `</div>`;
return;
}
const fonts = { mono: 'monospace', sans: 'system-ui,sans-serif', serif: 'Georgia,serif' };
box.innerHTML = msgs.map(m => {
const mine = m.from === _vvNc.me;
const fresh = (m.ts || 0) > _vvNc.lastRead && !mine;
return `<div style="padding:4px 6px;margin-bottom:3px;border-left:2px solid ${fresh ? '#4caf50' : '#232323'};
background:${fresh ? 'rgba(76,175,80,.04)' : 'transparent'};border-radius:0 3px 3px 0;">
<div style="display:flex;align-items:baseline;gap:8px;">
<span style="font-size:10px;font-weight:600;color:${mine ? '#4caf50' : '#7a8fa6'};">${vvEscHtml(_vvNcWho(m.from))}</span>
<span style="font-size:9px;color:#333;">${new Date((m.ts || 0) * 1000).toLocaleString()}</span>
<span style="margin-left:auto;font-size:10px;color:#333;cursor:pointer;"
title="Delete on this machine only" onclick="vvNcDel('${vvEscAttr(m.id)}')">×</span>
</div>
<div style="${_vvNcStyle(m, mine)}white-space:pre-wrap;word-break:break-word;margin-top:1px;">${vvEscHtml(m.text)}</div>
</div>`;
}).join('');
// Only when asked. Someone reading back through a thread must not be yanked to the bottom by
// the 15s poll landing a new line.
if (document.getElementById('vv-nc-follow')?.checked) box.scrollTop = box.scrollHeight;
// Marking read is what makes "unread only" mean anything next time, so it happens on view —
// but not while the filter is on, or the list would empty itself as you read it.
if (!unreadOnly && msgs.length) {
fetch('/plugins/varaverk/api/node_chat.php', {
method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({ csrf_token: typeof csrf_token !== 'undefined' ? csrf_token : '',
action: 'read', channel: _vvNc.ch })
}).catch(() => {});
}
}
function vvNcEmoji(sel) {
const t = document.getElementById('vv-nc-text');
if (t && sel.value) { t.value += sel.value; t.focus(); }
sel.selectedIndex = 0;
}
function vvNcSend() {
const t = document.getElementById('vv-nc-text');
const text = (t.value || '').trim();
if (!text) return;
const st = document.getElementById('vv-nc-status');
st.textContent = 'sending…';
fetch('/plugins/varaverk/api/node_chat.php', {
method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({
csrf_token: typeof csrf_token !== 'undefined' ? csrf_token : '',
action: 'send', channel: _vvNc.ch, text,
color: document.getElementById('vv-nc-color').value,
font: document.getElementById('vv-nc-font').value,
size: document.getElementById('vv-nc-size').value,
bold: _vvNc.fmt.bold ? '1' : '',
italic: _vvNc.fmt.italic ? '1' : '',
underline: _vvNc.fmt.underline ? '1' : '',
})
}).then(r => r.json()).then(d => {
if (!d.ok) { st.textContent = d.error || 'failed'; return; }
t.value = '';
// Queued is not failed. A partner that is asleep gets it on the next flush, and saying so is
// the difference between "it did not send" and "they are not awake".
st.textContent = d.queued ? `queued for ${d.queued} offline node(s)` : 'sent';
setTimeout(() => { st.textContent = ''; }, 4000);
vvNcLoad();
}).catch(e => { st.textContent = 'error'; });
}
function vvNcDel(id) {
fetch('/plugins/varaverk/api/node_chat.php', {
method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({ csrf_token: typeof csrf_token !== 'undefined' ? csrf_token : '',
action: 'delete', channel: _vvNc.ch, id })
}).then(() => vvNcLoad()).catch(() => {});
}
// ── Mode switch ───────────────────────────────────────────────────────────────
// One card, two panes. The label always names where the button GOES, not where you are — a
// button reading "Mesh" while showing mesh is the commonest way a toggle gets pressed twice.
let _vvNcPrefix = '', _vvNcMeshOn = false;
function vvNcMode(force) {
const p = _vvNcPrefix;
if (!p) return;
const ai = document.getElementById(p + '-pane-ai');
const me = document.getElementById(p + '-pane-mesh');
if (!ai || !me) return;
_vvNcMeshOn = (force === undefined) ? !_vvNcMeshOn : !!force;
ai.hidden = _vvNcMeshOn;
me.hidden = !_vvNcMeshOn;
const lbl = document.getElementById(p + '-mode-l');
if (lbl) lbl.textContent = _vvNcMeshOn ? 'Assistant' : 'Mesh';
const t = document.getElementById(p + '-title');
if (t) t.textContent = _vvNcMeshOn ? 'Mesh Chat' : (t.dataset.ai || t.textContent);
try { localStorage.setItem('vvNcMode:' + p, _vvNcMeshOn ? '1' : '0'); } catch (_) {}
// Opening the mesh resets the backoff, so switching to it never waits on a 90-second timer.
if (_vvNcMeshOn) { _vvNcStep = 0; vvNcLoad(); }
if (typeof _vvNcSchedule === 'function') _vvNcSchedule();
}
function vvNcInit(prefix, meshDefault) {
_vvNcPrefix = prefix;
const t = document.getElementById(prefix + '-title');
if (t && !t.dataset.ai) t.dataset.ai = t.textContent.trim();
// meshDefault means "this page IS the mesh page" — Partnership opens on it every time rather
// than on whatever was last toggled. Everywhere else the choice is remembered, because there
// the mesh is a thing you switch to and being returned to it is the surprise.
let want = !!meshDefault;
if (!meshDefault) {
try {
const s = localStorage.getItem('vvNcMode:' + prefix);
if (s !== null) want = s === '1';
} catch (_) {}
}
_vvNcLoadPrefs();
vvNcMode(want);
vvNcChans();
_vvNcSchedule();
}
// Poll fast while the mesh is on screen, and back off while it is not.
//
// Eight pages each asking every 15 seconds is eight times the traffic for one store, and while
// the card is showing the assistant the only thing the poll feeds is the unread badge — which
// nobody is watching to the second. Backs off 30 → 60 → 90 and stays there; snaps back to 15 the
// moment the mesh pane is opened, so switching to it is never waiting on a slow timer.
let _vvNcTimer = null, _vvNcStep = 0;
const _VV_NC_BACKOFF = [30000, 60000, 90000];
function _vvNcSchedule() {
if (_vvNcTimer) clearTimeout(_vvNcTimer);
const wait = _vvNcMeshOn ? 15000 : _VV_NC_BACKOFF[Math.min(_vvNcStep, _VV_NC_BACKOFF.length - 1)];
_vvNcTimer = setTimeout(() => {
vvNcChans();
if (!_vvNcMeshOn) _vvNcStep++;
_vvNcSchedule();
}, wait);
}
vvNcInit(<?= json_encode($prefix) ?>, <?= $meshDefault ? 'true' : 'false' ?>);
</script>
<?php
}