'use strict'; /** * Overflow Tap — server entry. * * Does what Overflow's "ProPresenter Integration" helper app does, inside * ProdDash: watches the live slide in ProPresenter, and when its notes carry * one of the admin page's keywords, points the chosen Overflow Tap groups at * that keyword's destination — so what people reach when they tap a disc * follows what is on screen. A slide with no keyword changes nothing; the * last destination stays until the next keyworded slide. * * GET /state → the view-model every tile draws * GET /stream → SSE, `state` events on every change (heartbeated) * POST /refresh → re-read Overflow's groups and destinations now * GET /groups → optionsRoute for the admin page's Tap groups picker * GET /destinations → the destinations, for a quick look while setting up * * ProPresenter is polled (GET /v1/status/slide, twice a second — one small * request, nothing heavier) because its chunked update stream is the thing * that goes quiet without saying so. Overflow is read on start, every few * minutes after that, and on /refresh; every switch is confirmed by reading * the group back. Which Overflow call does the switching, and why that call * is the one unverified piece, is explained at the top of overflow.js. * * OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720 points the module at * tools/overflow-tap-mock.js, which also plays a ProPresenter on :1602. */ const { createOverflowClient, OverflowError, looksLikeId } = require('./overflow'); const { parseKeywordLines, matchKeyword, cleanNotes, norm } = require('./keywords'); const POLL_MS = 500; // ProPresenter, while it answers const RETRY_MS = 3000; // ProPresenter, while it does not const PP_TIMEOUT_MS = 4000; const HEARTBEAT_MS = 15000; const DESTINATIONS_REFRESH_MS = 5 * 60 * 1000; const GROUPS_REFRESH_MS = 60 * 1000; // also how often drift (a change made in Overflow by hand) is noticed const OVERFLOW_RETRY_MS = 30 * 1000; const SWITCH_RETRY_MS = 10 * 1000; const SWITCH_RETRIES = 3; const HISTORY_MAX = 8; // the tile shows four; a few more for the hover const ALERT_SECONDS = 30; const MATCH_MODES = new Set(['word', 'anywhere', 'line']); /** Set by init(), read by routes() — both are rebuilt together on a config save. */ let current = null; function json(res, code, body) { res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }); res.end(JSON.stringify(body)); } /** One line for a log or a tile; a fetch failure names its cause (ECONNREFUSED, ENOTFOUND…). */ function describe(err) { if (err instanceof OverflowError) return err.message; if (err && err.name === 'AbortError') return 'timed out'; const msg = err && err.message ? err.message : String(err); const cause = err && err.cause && (err.cause.code || err.cause.message); return cause && !msg.includes(String(cause)) ? `${msg} (${cause})` : msg; } module.exports = { init({ config, log, shell }) { /* ── config ────────────────────────────────────────────────────── */ const ep = config.propresenter && typeof config.propresenter === 'object' ? config.propresenter : {}; const host = String(ep.host || '').trim(); const port = Number(ep.port) || 1025; const password = String(config.password || ''); const chosenGroups = (Array.isArray(config.groups) ? config.groups : []).map((g) => String(g || '').trim()).filter(Boolean); const mappings = parseKeywordLines(config.keywords); const mode = MATCH_MODES.has(config.matching) ? config.matching : 'word'; const alertEnabled = Boolean(config.alertEnabled); const alertChannel = Math.min(6, Math.max(1, Math.round(Number(config.alertChannel) || 1))); const overflow = createOverflowClient({ clientId: config.clientId, apiKey: config.apiKey }); /* ── state ─────────────────────────────────────────────────────── */ const streams = new Set(); const timers = new Set(); let stopped = false; let lastFrame = ''; let destinations = []; // Overflow's, as listed let groups = []; // Overflow's, as listed (each with its current destinationId) let resolved = []; // the mappings, resolved against those two lists let lastSlideKey = null; // change detection on the live slide let wanted = null; // the resolved keyword Tap should be on let inFlight = false; let retriesLeft = 0; let retryTimer = null; let alertRaised = false; const state = { status: 'connecting', message: '', propresenter: { configured: Boolean(host), reachable: false, error: '', host, port }, overflow: { configured: overflow.configured, reachable: false, error: '', groups: 0, destinations: 0, loadedAt: 0 }, keywords: [], slide: { live: false, keyword: '', unmapped: '', hasNotes: false }, current: null, history: [], updatedAt: 0, }; /* ── timers that die with the handle ───────────────────────────── */ function later(fn, ms) { const t = setTimeout(() => { timers.delete(t); if (!stopped) fn(); }, ms); t.unref?.(); timers.add(t); return t; } /* ── health, shared by the admin line and the tiles ────────────── */ function assess() { const pp = state.propresenter; const of = state.overflow; if (!pp.configured) return ['error', 'No ProPresenter host configured — set one in /admin']; if (!of.configured) return ['error', 'Overflow client ID and API key not set — add them in /admin']; if (!mappings.length) return ['error', 'No keywords configured — add some in /admin']; if (!of.reachable) return ['error', of.error || 'Reaching Overflow…']; if (!pp.reachable) return ['error', pp.error ? `ProPresenter unreachable: ${pp.error}` : 'Reaching ProPresenter…']; const bad = state.keywords.filter((k) => k.problem); if (bad.length && bad.length === state.keywords.length) { return ['error', `No keyword maps to a destination — ${bad[0].keyword}: ${bad[0].problem}`]; } if (state.current && !state.current.pending && !state.current.ok) { return ['error', `Tap not switched to ${state.current.keyword} — ${state.current.error}`]; } if (state.current && state.current.pending) return ['connecting', `Switching Tap to ${state.current.keyword}…`]; const extra = bad.length ? ` · ${bad.length} keyword${bad.length === 1 ? '' : 's'} without a destination: ${bad.map((k) => k.keyword).join(', ')}` : ''; const drift = state.current && state.current.drift ? ` · ${state.current.drift}` : ''; return ['ok', `Following ${host}:${port} · Overflow: ${of.groups} group${of.groups === 1 ? '' : 's'}, ${of.destinations} destination${of.destinations === 1 ? '' : 's'}, ${state.keywords.length} keyword${state.keywords.length === 1 ? '' : 's'}${extra}${drift}`]; } function publish() { if (stopped) return; [state.status, state.message] = assess(); const { updatedAt, ...rest } = state; let frame; try { frame = JSON.stringify(rest); } catch { return; } if (frame === lastFrame) return; lastFrame = frame; state.updatedAt = Date.now(); const payload = `event: state\ndata: ${JSON.stringify(state)}\n\n`; for (const res of streams) { try { res.write(payload); } catch { streams.delete(res); } } } /* ── Overflow: destinations, groups, and the keywords resolved ─── */ function resolveKeywords() { const dById = new Map(destinations.map((d) => [String(d.id), d])); const dByName = new Map(destinations.map((d) => [norm(d.name), d])); const gById = new Map(groups.map((g) => [String(g.id), g])); const gByName = new Map(groups.map((g) => [norm(g.name), g])); const defaults = chosenGroups.length ? chosenGroups.map((id) => gById.get(id)).filter(Boolean) : groups.slice(); resolved = mappings.map((m) => { const out = { keyword: m.keyword, destination: m.destination, destinationId: '', groups: [], problem: m.problem }; if (out.problem) return out; const d = (looksLikeId(m.destination) && dById.get(m.destination.trim())) || dByName.get(norm(m.destination)); if (!d) { out.problem = destinations.length ? `No Overflow destination named "${m.destination}"` : 'Overflow destinations not read yet'; return out; } out.destination = String(d.name || m.destination); out.destinationId = String(d.id); let gs; if (m.groups.length) { gs = []; const missing = []; for (const name of m.groups) { const g = (looksLikeId(name) && gById.get(name)) || gByName.get(norm(name)); if (g) gs.push(g); else missing.push(name); } if (missing.length) { out.problem = `No Tap group named ${missing.map((x) => `"${x}"`).join(', ')}`; return out; } } else { gs = defaults; } if (!gs.length) { out.problem = groups.length ? 'None of the chosen Tap groups exist any more' : 'No Tap groups on this Overflow account'; return out; } out.groups = gs.map((g) => ({ id: String(g.id), name: String(g.name || g.id) })); return out; }); state.keywords = resolved.map((r) => ({ keyword: r.keyword, destination: r.destination, groups: r.groups.map((g) => g.name), problem: r.problem, })); } /** Has anyone moved a group we switched? Noted on the live entry, never fought. */ function noteDrift() { const cur = state.current; if (!cur || !cur.ok || cur.pending) return; const byId = new Map(groups.map((g) => [String(g.id), g])); const moved = []; for (const g of cur.groupIds || []) { const now = byId.get(g); if (!now) continue; if (String(now.destinationId || '') !== cur.destination.id) { moved.push(`${now.name} now points at ${now.destination && now.destination.name ? now.destination.name : 'another destination'}`); } } cur.drift = moved.length ? `changed in Overflow: ${moved.join('; ')}` : ''; } let destinationsTimer = null; let groupsTimer = null; let loading = null; async function loadOverflow(what = 'all') { if (stopped || !overflow.configured) return; if (loading) return loading; loading = (async () => { try { if (what === 'all' || what === 'destinations') destinations = await overflow.listDestinations(); groups = await overflow.listGroups(); state.overflow.reachable = true; state.overflow.error = ''; state.overflow.groups = groups.length; state.overflow.destinations = destinations.length; state.overflow.loadedAt = Date.now(); resolveKeywords(); noteDrift(); // A slide that was already up when the lists arrived (or when a // destination got its name fixed in Overflow) should take effect now. if (lastSlideKey !== null) reconsiderSlide(); } catch (err) { state.overflow.reachable = false; state.overflow.error = describe(err); log(`Overflow: ${state.overflow.error}`); if (!resolved.length) resolveKeywords(); // so the keyword list at least shows the lines } publish(); })(); try { await loading; } finally { loading = null; } scheduleOverflow(); } function scheduleOverflow() { if (stopped) return; if (destinationsTimer) clearTimeout(destinationsTimer); if (groupsTimer) clearTimeout(groupsTimer); timers.delete(destinationsTimer); timers.delete(groupsTimer); const ok = state.overflow.reachable; destinationsTimer = later(() => loadOverflow('all'), ok ? DESTINATIONS_REFRESH_MS : OVERFLOW_RETRY_MS); groupsTimer = ok ? later(() => loadOverflow('groups'), GROUPS_REFRESH_MS) : null; } /* ── the switch ────────────────────────────────────────────────── */ function pushHistory(entry) { const { pending, drift, ...rest } = entry; state.history.unshift(rest); if (state.history.length > HISTORY_MAX) state.history.length = HISTORY_MAX; } function raiseAlert(keyword) { if (!alertEnabled || !shell || typeof shell.alert !== 'function') return; alertRaised = true; Promise.resolve(shell.alert({ channel: alertChannel, level: 'medium', text: `Tap not switched: ${keyword}`, duration: ALERT_SECONDS })) .catch((err) => log(`alert refused: ${describe(err)}`)); } function clearAlert() { if (!alertRaised || !shell || typeof shell.clearAlert !== 'function') return; alertRaised = false; Promise.resolve(shell.clearAlert(alertChannel)).catch(() => {}); } function requestSwitch(target) { wanted = target; retriesLeft = SWITCH_RETRIES; if (retryTimer) { clearTimeout(retryTimer); timers.delete(retryTimer); retryTimer = null; } runSwitch(); } async function runSwitch() { if (stopped || inFlight || !wanted) return; const target = wanted; const cur = state.current; const sameTarget = cur && cur.keyword === target.keyword && cur.destination.id === target.destinationId; if (sameTarget && cur.ok) return; // already there inFlight = true; let entry; if (sameTarget) { entry = cur; // another go at the same switch } else { if (cur) pushHistory(cur); entry = { keyword: target.keyword, destination: { id: target.destinationId, name: target.destination }, groups: target.groups.map((g) => g.name), groupIds: target.groups.map((g) => g.id), at: Date.now(), ok: false, pending: true, error: '', attempts: 0, drift: '', }; state.current = entry; } entry.pending = true; entry.attempts += 1; publish(); try { for (const g of target.groups) { if (stopped) return; await overflow.setGroupDestination(g.id, target.destinationId); const check = await overflow.getGroup(g.id); const nowAt = check ? String(check.destinationId || '') : ''; if (check && nowAt !== target.destinationId) { const name = check.destination && check.destination.name ? `"${check.destination.name}"` : 'another destination'; throw new OverflowError(`Overflow accepted the request but "${g.name}" still points at ${name}`, { status: 200 }); } const cached = groups.find((x) => String(x.id) === g.id); if (cached) { cached.destinationId = target.destinationId; cached.destination = { id: target.destinationId, name: target.destination }; } } entry.ok = true; entry.error = ''; entry.at = Date.now(); entry.drift = ''; clearAlert(); log(`Tap → ${target.destination} (${target.keyword}) on ${target.groups.map((g) => g.name).join(', ')}`); } catch (err) { if (stopped) return; entry.ok = false; entry.error = describe(err); log(`Tap not switched to ${target.destination} (${target.keyword}): ${entry.error}`); if (retriesLeft > 0) { retriesLeft -= 1; retryTimer = later(() => { retryTimer = null; runSwitch(); }, SWITCH_RETRY_MS); } raiseAlert(target.keyword); } finally { entry.pending = false; inFlight = false; publish(); if (!stopped && wanted !== target) runSwitch(); // a newer keyword arrived meanwhile } } /* ── ProPresenter: the live slide ──────────────────────────────── */ function ppUrl(target) { const u = new URL(`http://${host}:${port}${target}`); if (password) u.searchParams.set('password', password); return u; } async function ppJson(target) { const ctl = new AbortController(); const timer = setTimeout(() => ctl.abort(), PP_TIMEOUT_MS); try { const res = await fetch(ppUrl(target), { headers: { Accept: 'application/json' }, signal: ctl.signal }); if (!res.ok) throw new Error(`ProPresenter ${res.status} ${res.statusText}`); const text = await res.text(); return text ? JSON.parse(text) : null; } finally { clearTimeout(timer); } } let lastSlide = null; function reconsiderSlide() { const cur = lastSlide; if (!cur) { state.slide = { live: false, keyword: '', unmapped: '', hasNotes: false }; return; } const notes = cleanNotes(cur.notes); const hit = matchKeyword(notes, resolved, mode); state.slide = { live: true, keyword: hit ? hit.keyword : '', unmapped: hit && hit.problem ? hit.problem : '', hasNotes: Boolean(notes.trim()), }; if (hit && !hit.problem) requestSwitch(hit); } async function pollSlide() { if (stopped) return; let ok = false; try { const data = await ppJson('/v1/status/slide'); const cur = data && data.current && typeof data.current === 'object' ? data.current : null; state.propresenter.reachable = true; state.propresenter.error = ''; ok = true; const key = cur ? `${cur.uuid || ''}\u0000${cur.text || ''}\u0000${cur.notes || ''}` : ''; if (key !== lastSlideKey) { lastSlideKey = key; lastSlide = cur; reconsiderSlide(); } } catch (err) { state.propresenter.reachable = false; state.propresenter.error = err && err.name === 'AbortError' ? 'timed out' : describe(err); } publish(); later(pollSlide, ok ? POLL_MS : RETRY_MS); } /* ── go ────────────────────────────────────────────────────────── */ resolveKeywords(); if (host) { pollSlide(); log(`following ProPresenter at ${host}:${port}`); } else { log('no ProPresenter host configured — set one in /admin'); } if (overflow.configured) { loadOverflow('all'); log(`Overflow at ${overflow.base}`); } else { log('Overflow client ID / API key not set — add them in /admin'); } publish(); const heartbeat = setInterval(() => { for (const res of streams) { try { res.write(': ping\n\n'); } catch { streams.delete(res); } } }, HEARTBEAT_MS); heartbeat.unref?.(); const handle = { streams, getState: () => state, refresh: () => loadOverflow('all'), groupOptions: () => groups.map((g) => ({ value: String(g.id), label: String(g.name || g.id) })), destinationOptions: () => destinations.map((d) => ({ value: String(d.id), label: String(d.name || d.id) })), configured: overflow.configured, }; current = handle; return { stop() { stopped = true; clearInterval(heartbeat); for (const t of timers) clearTimeout(t); timers.clear(); clearAlert(); for (const res of streams) { try { res.end(); } catch { /* already gone */ } } streams.clear(); if (current === handle) current = null; }, health() { const [status, message] = assess(); return { status, message }; }, }; }, routes() { return { 'GET /state': (req, res) => { if (!current) return json(res, 503, { error: 'Overflow Tap is not running.' }); json(res, 200, { state: current.getState() }); }, 'GET /stream': (req, res) => { res.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-cache, no-transform', Connection: 'keep-alive', 'X-Accel-Buffering': 'no', }); res.write('retry: 3000\n\n'); if (!current) return void res.end(); res.write(`event: state\ndata: ${JSON.stringify(current.getState())}\n\n`); try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ } current.streams.add(res); req.on('close', () => current && current.streams.delete(res)); }, 'POST /refresh': async (req, res) => { if (!current) return json(res, 503, { error: 'Overflow Tap is not running.' }); if (!current.configured) return json(res, 400, { error: 'Overflow client ID and API key are not set.' }); await current.refresh(); const st = current.getState(); if (!st.overflow.reachable) return json(res, 502, { error: st.overflow.error || 'Overflow unreachable.' }); json(res, 200, { ok: true, groups: st.overflow.groups, destinations: st.overflow.destinations }); }, 'GET /groups': async (req, res) => { if (!current) return json(res, 200, { options: [] }); if (current.configured && !current.groupOptions().length) await current.refresh(); json(res, 200, { options: current.groupOptions() }); }, 'GET /destinations': async (req, res) => { if (!current) return json(res, 200, { options: [] }); if (current.configured && !current.destinationOptions().length) await current.refresh(); json(res, 200, { options: current.destinationOptions() }); }, }; }, };