2026-10-07 14:00:37 -04:00
|
|
|
|
'use strict';
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Overflow Tap — server entry.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Does what Overflow's "ProPresenter Integration" helper app does, inside
|
2026-10-11 10:25:02 -04:00
|
|
|
|
* ProdDash: watches the live slide in ProPresenter (the presentation layer
|
|
|
|
|
|
* and the announcement layer), and when its notes carry one of the keywords
|
|
|
|
|
|
* mapped in the Overflow Dashboard, tells Overflow — which points that
|
|
|
|
|
|
* keyword's Tap groups at its 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.
|
2026-10-07 14:00:37 -04:00
|
|
|
|
*
|
2026-10-11 10:25:02 -04:00
|
|
|
|
* GET /state → the view-model every tile draws
|
|
|
|
|
|
* GET /stream → SSE, `state` events on every change (heartbeated)
|
|
|
|
|
|
* POST /refresh → re-read the keyword mappings from the Dashboard now
|
|
|
|
|
|
* GET /keywords → the mappings as read, for a look while setting up
|
2026-10-07 14:00:37 -04:00
|
|
|
|
*
|
2026-10-11 10:25:02 -04:00
|
|
|
|
* ProPresenter is polled (GET /v1/status/slide for the presentation layer,
|
|
|
|
|
|
* GET /v1/announcement/slide_index for announcements, once a second each —
|
|
|
|
|
|
* small requests, nothing heavier) because its chunked update stream is the
|
|
|
|
|
|
* thing that goes quiet without saying so. Overflow is the Dashboard's own
|
|
|
|
|
|
* API, signed in with a Dashboard login — the calls, and where they come
|
|
|
|
|
|
* from, are at the top of overflow.js. Keyword mappings are read on start,
|
|
|
|
|
|
* every few minutes after that, on /refresh, and from every trigger's answer.
|
2026-10-07 14:00:37 -04:00
|
|
|
|
*
|
|
|
|
|
|
* 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.
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
2026-10-11 10:25:02 -04:00
|
|
|
|
const crypto = require('crypto');
|
|
|
|
|
|
const { createOverflowClient, OverflowError, describeSession } = require('./overflow');
|
|
|
|
|
|
const { matchKeywords, sameSet, cleanNotes, norm } = require('./keywords');
|
2026-10-07 14:00:37 -04:00
|
|
|
|
|
2026-10-11 10:25:02 -04:00
|
|
|
|
const POLL_MS = 1000; // ProPresenter, while it answers
|
|
|
|
|
|
const RETRY_MS = 3000; // ProPresenter, while it does not
|
2026-10-07 14:00:37 -04:00
|
|
|
|
const PP_TIMEOUT_MS = 4000;
|
|
|
|
|
|
const HEARTBEAT_MS = 15000;
|
2026-10-11 10:25:02 -04:00
|
|
|
|
const KEYWORDS_REFRESH_MS = 5 * 60 * 1000;
|
2026-10-07 14:00:37 -04:00
|
|
|
|
const OVERFLOW_RETRY_MS = 30 * 1000;
|
2026-10-11 10:25:02 -04:00
|
|
|
|
const LOGIN_MIN_GAP_MS = 20 * 1000; // a refused sign-in is not retried sooner (429 = "too many attempts")
|
|
|
|
|
|
const SESSION_MARGIN_MS = 60 * 1000; // sign in again this long before the token expires
|
2026-10-07 14:00:37 -04:00
|
|
|
|
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;
|
|
|
|
|
|
|
|
|
|
|
|
/** Set by init(), read by routes() — both are rebuilt together on a config save. */
|
|
|
|
|
|
let current = null;
|
|
|
|
|
|
|
2026-10-11 10:25:02 -04:00
|
|
|
|
/**
|
|
|
|
|
|
* The signed-in session, kept across re-inits for the same login: an admin
|
|
|
|
|
|
* saving config five times in a minute must not become five sign-ins
|
|
|
|
|
|
* (Overflow refuses that with 429). Keyed on a hash of the login, so a
|
|
|
|
|
|
* changed email or password starts fresh; the token itself never leaves the
|
|
|
|
|
|
* server process.
|
|
|
|
|
|
*/
|
|
|
|
|
|
let sessionCache = null; // { key, token, expiresAt, email, nonprofitId }
|
|
|
|
|
|
|
2026-10-07 14:00:37 -04:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-11 10:25:02 -04:00
|
|
|
|
function unique(list) {
|
|
|
|
|
|
const seen = new Set();
|
|
|
|
|
|
const out = [];
|
|
|
|
|
|
for (const v of list) {
|
|
|
|
|
|
const k = norm(v);
|
|
|
|
|
|
if (!k || seen.has(k)) continue;
|
|
|
|
|
|
seen.add(k);
|
|
|
|
|
|
out.push(v);
|
|
|
|
|
|
}
|
|
|
|
|
|
return out;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** The destination and group names Overflow attached to a keyword mapping, whatever the field names turn out to be. */
|
|
|
|
|
|
function destinationOf(entry) {
|
|
|
|
|
|
return String((entry && (entry.destinationName || (entry.destination && entry.destination.name))) || '');
|
|
|
|
|
|
}
|
|
|
|
|
|
function groupsOf(entry) {
|
|
|
|
|
|
const list = entry && Array.isArray(entry.tapGroups) ? entry.tapGroups : [];
|
|
|
|
|
|
return list.map((g) => String((g && (g.tapGroupName || g.name || g.id)) || '')).filter(Boolean);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-07 14:00:37 -04:00
|
|
|
|
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;
|
2026-10-11 10:25:02 -04:00
|
|
|
|
const ppPassword = String(config.password || '');
|
|
|
|
|
|
const email = String(config.overflowEmail || '').trim();
|
|
|
|
|
|
const ofPassword = String(config.overflowPassword || '');
|
|
|
|
|
|
const staffNonprofit = String(config.nonprofitId || '').trim();
|
|
|
|
|
|
const configured = Boolean(email && ofPassword);
|
2026-10-07 14:00:37 -04:00
|
|
|
|
const alertEnabled = Boolean(config.alertEnabled);
|
|
|
|
|
|
const alertChannel = Math.min(6, Math.max(1, Math.round(Number(config.alertChannel) || 1)));
|
2026-10-11 10:25:02 -04:00
|
|
|
|
const sessionKey = crypto.createHash('sha256').update(`${email}\n${ofPassword}`).digest('hex');
|
2026-10-07 14:00:37 -04:00
|
|
|
|
|
2026-10-11 10:25:02 -04:00
|
|
|
|
const overflow = createOverflowClient();
|
2026-10-07 14:00:37 -04:00
|
|
|
|
|
|
|
|
|
|
/* ── state ─────────────────────────────────────────────────────── */
|
|
|
|
|
|
const streams = new Set();
|
|
|
|
|
|
const timers = new Set();
|
|
|
|
|
|
let stopped = false;
|
|
|
|
|
|
let lastFrame = '';
|
|
|
|
|
|
|
2026-10-11 10:25:02 -04:00
|
|
|
|
let keywordCache = new Map(); // norm(keyword) → the Dashboard's entry
|
|
|
|
|
|
let session = null; // { token, expiresAt, email, nonprofitId }
|
|
|
|
|
|
let lastLoginAt = 0;
|
|
|
|
|
|
let lastLoginError = null;
|
|
|
|
|
|
let wanted = null; // the keywords Tap should be on: { keywords, keyword, destination, groups }
|
2026-10-07 14:00:37 -04:00
|
|
|
|
let inFlight = false;
|
|
|
|
|
|
let retriesLeft = 0;
|
|
|
|
|
|
let retryTimer = null;
|
|
|
|
|
|
let alertRaised = false;
|
|
|
|
|
|
|
2026-10-11 10:25:02 -04:00
|
|
|
|
// Presentation layer: the slide /v1/status/slide reports.
|
|
|
|
|
|
let lastSlideKey = null;
|
|
|
|
|
|
let lastSlide = null;
|
|
|
|
|
|
let lastPresKeywords = [];
|
|
|
|
|
|
// Announcement layer: the slide /v1/announcement/slide_index points at, read from /v1/announcement/active.
|
|
|
|
|
|
let annSupported = true;
|
|
|
|
|
|
let lastAnnKey = null;
|
|
|
|
|
|
let annNotes = '';
|
|
|
|
|
|
let annLive = false;
|
|
|
|
|
|
let lastAnnKeywords = [];
|
|
|
|
|
|
|
2026-10-07 14:00:37 -04:00
|
|
|
|
const state = {
|
|
|
|
|
|
status: 'connecting',
|
|
|
|
|
|
message: '',
|
|
|
|
|
|
propresenter: { configured: Boolean(host), reachable: false, error: '', host, port },
|
2026-10-11 10:25:02 -04:00
|
|
|
|
overflow: { configured, reachable: false, error: '', keywords: 0, loadedAt: 0 },
|
2026-10-07 14:00:37 -04:00
|
|
|
|
keywords: [],
|
2026-10-11 10:25:02 -04:00
|
|
|
|
slide: { live: false, keywords: [], hasNotes: false },
|
|
|
|
|
|
announcement: { live: false, keywords: [] },
|
2026-10-07 14:00:37 -04:00
|
|
|
|
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 ────────────── */
|
|
|
|
|
|
|
2026-10-11 10:25:02 -04:00
|
|
|
|
function assess(forAdmin = false) {
|
2026-10-07 14:00:37 -04:00
|
|
|
|
const pp = state.propresenter;
|
|
|
|
|
|
const of = state.overflow;
|
|
|
|
|
|
if (!pp.configured) return ['error', 'No ProPresenter host configured — set one in /admin'];
|
2026-10-11 10:25:02 -04:00
|
|
|
|
if (!of.configured) return ['error', 'Overflow login not set — add the email and password in /admin'];
|
|
|
|
|
|
if (!of.reachable) return ['error', of.error || 'Signing in to Overflow…'];
|
2026-10-07 14:00:37 -04:00
|
|
|
|
if (!pp.reachable) return ['error', pp.error ? `ProPresenter unreachable: ${pp.error}` : 'Reaching ProPresenter…'];
|
2026-10-11 10:25:02 -04:00
|
|
|
|
if (!state.keywords.length) return ['error', 'No keyword mappings in the Overflow Dashboard (Integrations › ProPresenter)'];
|
2026-10-07 14:00:37 -04:00
|
|
|
|
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}…`];
|
2026-10-11 10:25:02 -04:00
|
|
|
|
const n = state.keywords.length;
|
|
|
|
|
|
const who = forAdmin && session && session.email ? `${session.email}, ` : '';
|
|
|
|
|
|
return ['ok', `Following ${host}:${port} · Overflow: ${who}${n} keyword${n === 1 ? '' : 's'}`];
|
2026-10-07 14:00:37 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function publish() {
|
|
|
|
|
|
if (stopped) return;
|
2026-10-11 10:25:02 -04:00
|
|
|
|
[state.status, state.message] = assess(false);
|
2026-10-07 14:00:37 -04:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-11 10:25:02 -04:00
|
|
|
|
/* ── the Overflow session ──────────────────────────────────────── */
|
2026-10-07 14:00:37 -04:00
|
|
|
|
|
2026-10-11 10:25:02 -04:00
|
|
|
|
async function ensureSession() {
|
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
|
if (session && now < session.expiresAt - SESSION_MARGIN_MS) return session;
|
|
|
|
|
|
if (sessionCache && sessionCache.key === sessionKey && now < sessionCache.expiresAt - SESSION_MARGIN_MS) {
|
|
|
|
|
|
session = sessionCache;
|
|
|
|
|
|
return session;
|
2026-10-07 14:00:37 -04:00
|
|
|
|
}
|
2026-10-11 10:25:02 -04:00
|
|
|
|
if (lastLoginError && now - lastLoginAt < LOGIN_MIN_GAP_MS) throw lastLoginError;
|
|
|
|
|
|
lastLoginAt = now;
|
|
|
|
|
|
let token;
|
|
|
|
|
|
try {
|
|
|
|
|
|
token = await overflow.login(email, ofPassword);
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
lastLoginError = err;
|
|
|
|
|
|
throw err;
|
|
|
|
|
|
}
|
|
|
|
|
|
lastLoginError = null;
|
|
|
|
|
|
const info = describeSession(token);
|
|
|
|
|
|
if (!info.hasTap) throw new OverflowError('This Overflow login has no Tap permission — use one with the Organization Admin or Tap Only role');
|
|
|
|
|
|
let nonprofitId = info.nonprofitId || (info.nonprofits.length === 1 ? info.nonprofits[0].id : '');
|
|
|
|
|
|
if (info.staff) {
|
|
|
|
|
|
if (!staffNonprofit) throw new OverflowError('An @overflow.co login has no nonprofit of its own — enter the Nonprofit ID in /admin');
|
|
|
|
|
|
await overflow.selectNonprofit(token, staffNonprofit);
|
|
|
|
|
|
nonprofitId = staffNonprofit;
|
|
|
|
|
|
} else if (!nonprofitId) {
|
|
|
|
|
|
nonprofitId = staffNonprofit;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!nonprofitId) throw new OverflowError('This Overflow login carries no nonprofit — enter the Nonprofit ID in /admin');
|
|
|
|
|
|
session = { token, expiresAt: info.expiresAt || now + 12 * 3600 * 1000, email: info.email || email, nonprofitId };
|
|
|
|
|
|
sessionCache = { key: sessionKey, ...session };
|
|
|
|
|
|
log(`signed in to Overflow as ${session.email} (nonprofit ${nonprofitId})`);
|
|
|
|
|
|
return session;
|
2026-10-07 14:00:37 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-11 10:25:02 -04:00
|
|
|
|
function dropSession() {
|
|
|
|
|
|
session = null;
|
|
|
|
|
|
if (sessionCache && sessionCache.key === sessionKey) sessionCache = null;
|
|
|
|
|
|
}
|
2026-10-07 14:00:37 -04:00
|
|
|
|
|
2026-10-11 10:25:02 -04:00
|
|
|
|
/** Run a signed-in call; when the session is refused, sign in again once and retry. */
|
|
|
|
|
|
async function withSession(fn) {
|
|
|
|
|
|
const s = await ensureSession();
|
|
|
|
|
|
try {
|
|
|
|
|
|
return await fn(s);
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
if (!(err instanceof OverflowError) || !err.auth) throw err;
|
|
|
|
|
|
dropSession();
|
|
|
|
|
|
const again = await ensureSession();
|
|
|
|
|
|
return fn(again);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ── the keyword mappings ──────────────────────────────────────── */
|
|
|
|
|
|
|
|
|
|
|
|
function rebuildKeywordView() {
|
|
|
|
|
|
state.keywords = [...keywordCache.values()].map((e) => ({
|
|
|
|
|
|
keyword: String(e.keyword).trim(),
|
|
|
|
|
|
destination: destinationOf(e),
|
|
|
|
|
|
groups: groupsOf(e),
|
|
|
|
|
|
}));
|
|
|
|
|
|
state.overflow.keywords = state.keywords.length;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Replace the cache with the Dashboard's list (`merge` folds a partial answer into it instead). */
|
|
|
|
|
|
function applyKeywordList(list, merge = false) {
|
|
|
|
|
|
const next = merge ? new Map(keywordCache) : new Map();
|
|
|
|
|
|
for (const entry of list || []) {
|
|
|
|
|
|
if (!entry || typeof entry !== 'object') continue;
|
|
|
|
|
|
const k = norm(entry.keyword);
|
|
|
|
|
|
if (!k) continue;
|
|
|
|
|
|
if (entry.active === false || entry.enabled === false || entry.isActive === false) {
|
|
|
|
|
|
next.delete(k);
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
next.set(k, entry);
|
|
|
|
|
|
}
|
|
|
|
|
|
keywordCache = next;
|
|
|
|
|
|
rebuildKeywordView();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let loading = null;
|
|
|
|
|
|
let refreshTimer = null;
|
|
|
|
|
|
|
|
|
|
|
|
async function loadKeywords() {
|
|
|
|
|
|
if (stopped || !configured) return;
|
2026-10-07 14:00:37 -04:00
|
|
|
|
if (loading) return loading;
|
|
|
|
|
|
loading = (async () => {
|
|
|
|
|
|
try {
|
2026-10-11 10:25:02 -04:00
|
|
|
|
const list = await withSession((s) => overflow.listKeywords(s.token, s.nonprofitId));
|
|
|
|
|
|
applyKeywordList(list);
|
2026-10-07 14:00:37 -04:00
|
|
|
|
state.overflow.reachable = true;
|
|
|
|
|
|
state.overflow.error = '';
|
|
|
|
|
|
state.overflow.loadedAt = Date.now();
|
2026-10-11 10:25:02 -04:00
|
|
|
|
reconsider(); // a slide already up when the mappings arrive takes effect now
|
2026-10-07 14:00:37 -04:00
|
|
|
|
} catch (err) {
|
|
|
|
|
|
state.overflow.reachable = false;
|
|
|
|
|
|
state.overflow.error = describe(err);
|
|
|
|
|
|
log(`Overflow: ${state.overflow.error}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
publish();
|
|
|
|
|
|
})();
|
|
|
|
|
|
try {
|
|
|
|
|
|
await loading;
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
loading = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (stopped) return;
|
2026-10-11 10:25:02 -04:00
|
|
|
|
if (refreshTimer) {
|
|
|
|
|
|
clearTimeout(refreshTimer);
|
|
|
|
|
|
timers.delete(refreshTimer);
|
|
|
|
|
|
}
|
|
|
|
|
|
refreshTimer = later(loadKeywords, state.overflow.reachable ? KEYWORDS_REFRESH_MS : OVERFLOW_RETRY_MS);
|
2026-10-07 14:00:37 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ── the switch ────────────────────────────────────────────────── */
|
|
|
|
|
|
|
2026-10-11 10:25:02 -04:00
|
|
|
|
function describeTarget(keys) {
|
|
|
|
|
|
const entries = keys.map((k) => keywordCache.get(norm(k))).filter(Boolean);
|
|
|
|
|
|
return {
|
|
|
|
|
|
keywords: keys,
|
|
|
|
|
|
keyword: keys.join(' + '),
|
|
|
|
|
|
destination: unique(entries.map(destinationOf).filter(Boolean)).join(' + '),
|
|
|
|
|
|
groups: unique(entries.flatMap(groupsOf)),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-07 14:00:37 -04:00
|
|
|
|
function pushHistory(entry) {
|
2026-10-11 10:25:02 -04:00
|
|
|
|
const { pending, ...rest } = entry;
|
2026-10-07 14:00:37 -04:00
|
|
|
|
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;
|
2026-10-11 10:25:02 -04:00
|
|
|
|
const sameTarget = cur && sameSet(cur.keywords, target.keywords);
|
2026-10-07 14:00:37 -04:00
|
|
|
|
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,
|
2026-10-11 10:25:02 -04:00
|
|
|
|
keywords: target.keywords,
|
|
|
|
|
|
destination: target.destination,
|
|
|
|
|
|
groups: target.groups,
|
2026-10-07 14:00:37 -04:00
|
|
|
|
at: Date.now(),
|
|
|
|
|
|
ok: false,
|
|
|
|
|
|
pending: true,
|
|
|
|
|
|
error: '',
|
|
|
|
|
|
attempts: 0,
|
|
|
|
|
|
};
|
|
|
|
|
|
state.current = entry;
|
|
|
|
|
|
}
|
|
|
|
|
|
entry.pending = true;
|
|
|
|
|
|
entry.attempts += 1;
|
|
|
|
|
|
publish();
|
|
|
|
|
|
try {
|
2026-10-11 10:25:02 -04:00
|
|
|
|
const answer = await withSession((s) => overflow.trigger(s.token, s.nonprofitId, target.keywords));
|
|
|
|
|
|
if (answer) {
|
|
|
|
|
|
applyKeywordList(answer, true);
|
|
|
|
|
|
const fresh = describeTarget(target.keywords);
|
|
|
|
|
|
entry.destination = fresh.destination || entry.destination;
|
|
|
|
|
|
entry.groups = fresh.groups.length ? fresh.groups : entry.groups;
|
2026-10-07 14:00:37 -04:00
|
|
|
|
}
|
|
|
|
|
|
entry.ok = true;
|
|
|
|
|
|
entry.error = '';
|
|
|
|
|
|
entry.at = Date.now();
|
|
|
|
|
|
clearAlert();
|
2026-10-11 10:25:02 -04:00
|
|
|
|
log(`Tap → ${entry.destination || '?'} (${target.keyword})${entry.groups.length ? ` on ${entry.groups.join(', ')}` : ''}`);
|
2026-10-07 14:00:37 -04:00
|
|
|
|
} catch (err) {
|
|
|
|
|
|
if (stopped) return;
|
|
|
|
|
|
entry.ok = false;
|
|
|
|
|
|
entry.error = describe(err);
|
2026-10-11 10:25:02 -04:00
|
|
|
|
log(`Tap not switched to ${target.keyword}: ${entry.error}`);
|
2026-10-07 14:00:37 -04:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-11 10:25:02 -04:00
|
|
|
|
/* ── what is on screen, and what to do about it ────────────────── */
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* The helper's rule, per layer: when the set of keywords the layer's
|
|
|
|
|
|
* slide carries changes to a non-empty set, send it. Returning to a
|
|
|
|
|
|
* keyword after a slide without one sends it again; staying on the same
|
|
|
|
|
|
* keyword across slides sends nothing.
|
|
|
|
|
|
*/
|
|
|
|
|
|
function reconsider() {
|
|
|
|
|
|
const list = [...keywordCache.values()];
|
|
|
|
|
|
const presNotes = lastSlide ? cleanNotes(lastSlide.notes) : '';
|
|
|
|
|
|
const presKeys = lastSlide ? matchKeywords(presNotes, list) : [];
|
|
|
|
|
|
state.slide = { live: Boolean(lastSlide), keywords: presKeys, hasNotes: Boolean(presNotes.trim()) };
|
|
|
|
|
|
const presChanged = !sameSet(presKeys, lastPresKeywords);
|
|
|
|
|
|
if (presChanged) lastPresKeywords = presKeys;
|
|
|
|
|
|
|
|
|
|
|
|
const annKeys = annLive ? matchKeywords(annNotes, list) : [];
|
|
|
|
|
|
state.announcement = { live: annLive, keywords: annKeys };
|
|
|
|
|
|
const annChanged = !sameSet(annKeys, lastAnnKeywords);
|
|
|
|
|
|
if (annChanged) lastAnnKeywords = annKeys;
|
|
|
|
|
|
|
|
|
|
|
|
const batch = [];
|
|
|
|
|
|
if (presChanged && presKeys.length) batch.push(...presKeys);
|
|
|
|
|
|
if (annChanged && annKeys.length) batch.push(...annKeys);
|
|
|
|
|
|
if (batch.length) requestSwitch(describeTarget(unique(batch)));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ── ProPresenter ──────────────────────────────────────────────── */
|
2026-10-07 14:00:37 -04:00
|
|
|
|
|
|
|
|
|
|
function ppUrl(target) {
|
|
|
|
|
|
const u = new URL(`http://${host}:${port}${target}`);
|
2026-10-11 10:25:02 -04:00
|
|
|
|
if (ppPassword) u.searchParams.set('password', ppPassword);
|
2026-10-07 14:00:37 -04:00
|
|
|
|
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 });
|
2026-10-11 10:25:02 -04:00
|
|
|
|
if (!res.ok) {
|
|
|
|
|
|
const err = new Error(`ProPresenter ${res.status} ${res.statusText}`);
|
|
|
|
|
|
err.status = res.status;
|
|
|
|
|
|
throw err;
|
|
|
|
|
|
}
|
2026-10-07 14:00:37 -04:00
|
|
|
|
const text = await res.text();
|
|
|
|
|
|
return text ? JSON.parse(text) : null;
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
clearTimeout(timer);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-11 10:25:02 -04:00
|
|
|
|
/** The presentation layer: /v1/status/slide names the live slide and carries its notes. */
|
|
|
|
|
|
async function pollSlide() {
|
|
|
|
|
|
const data = await ppJson('/v1/status/slide');
|
|
|
|
|
|
const cur = data && data.current && typeof data.current === 'object' ? data.current : null;
|
|
|
|
|
|
const key = cur ? `${cur.uuid || ''}\u0000${cur.text || ''}\u0000${cur.notes || ''}` : '';
|
|
|
|
|
|
if (key === lastSlideKey) return false;
|
|
|
|
|
|
lastSlideKey = key;
|
|
|
|
|
|
lastSlide = cur;
|
|
|
|
|
|
return true;
|
2026-10-07 14:00:37 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-11 10:25:02 -04:00
|
|
|
|
/**
|
|
|
|
|
|
* The announcement layer: its slide index names the document and the
|
|
|
|
|
|
* slide; the document's slides (every group's, in order, disabled ones
|
|
|
|
|
|
* included — that is the index's space) carry the notes. An older
|
|
|
|
|
|
* ProPresenter without this API answers 404 once and is left alone.
|
|
|
|
|
|
*/
|
|
|
|
|
|
async function pollAnnouncement() {
|
|
|
|
|
|
if (!annSupported) return false;
|
|
|
|
|
|
let idx;
|
|
|
|
|
|
try {
|
|
|
|
|
|
idx = await ppJson('/v1/announcement/slide_index');
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
if (err && err.status === 404) {
|
|
|
|
|
|
annSupported = false;
|
|
|
|
|
|
log('this ProPresenter has no announcement layer API — following the presentation layer only');
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
throw err;
|
|
|
|
|
|
}
|
|
|
|
|
|
const node = idx && idx.announcement_index;
|
|
|
|
|
|
if (!node || !Number.isInteger(node.index)) {
|
|
|
|
|
|
if (lastAnnKey === '') return false;
|
|
|
|
|
|
lastAnnKey = '';
|
|
|
|
|
|
annLive = false;
|
|
|
|
|
|
annNotes = '';
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
const id = node.presentation_id || {};
|
|
|
|
|
|
const key = `${id.uuid || ''}\u0000${id.name || ''}\u0000${node.index}`;
|
|
|
|
|
|
if (key === lastAnnKey) return false;
|
|
|
|
|
|
const doc = await ppJson('/v1/announcement/active');
|
|
|
|
|
|
const ann = doc && doc.announcement;
|
|
|
|
|
|
const slides = [];
|
|
|
|
|
|
for (const g of (ann && Array.isArray(ann.groups) ? ann.groups : [])) {
|
|
|
|
|
|
for (const s of (g && Array.isArray(g.slides) ? g.slides : [])) slides.push(s);
|
|
|
|
|
|
}
|
|
|
|
|
|
const slide = slides[node.index];
|
|
|
|
|
|
annNotes = slide ? cleanNotes(slide.notes) : '';
|
|
|
|
|
|
annLive = true;
|
|
|
|
|
|
lastAnnKey = key;
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function pollTick() {
|
2026-10-07 14:00:37 -04:00
|
|
|
|
if (stopped) return;
|
|
|
|
|
|
let ok = false;
|
2026-10-11 10:25:02 -04:00
|
|
|
|
let changed = false;
|
2026-10-07 14:00:37 -04:00
|
|
|
|
try {
|
2026-10-11 10:25:02 -04:00
|
|
|
|
changed = await pollSlide();
|
2026-10-07 14:00:37 -04:00
|
|
|
|
state.propresenter.reachable = true;
|
|
|
|
|
|
state.propresenter.error = '';
|
|
|
|
|
|
ok = true;
|
2026-10-11 10:25:02 -04:00
|
|
|
|
try {
|
|
|
|
|
|
if (await pollAnnouncement()) changed = true;
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
/* the presentation poll already said ProPresenter answers; a hiccup here waits for the next tick */
|
2026-10-07 14:00:37 -04:00
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
state.propresenter.reachable = false;
|
|
|
|
|
|
state.propresenter.error = err && err.name === 'AbortError' ? 'timed out' : describe(err);
|
|
|
|
|
|
}
|
2026-10-11 10:25:02 -04:00
|
|
|
|
if (changed) reconsider();
|
2026-10-07 14:00:37 -04:00
|
|
|
|
publish();
|
2026-10-11 10:25:02 -04:00
|
|
|
|
later(pollTick, ok ? POLL_MS : RETRY_MS);
|
2026-10-07 14:00:37 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ── go ────────────────────────────────────────────────────────── */
|
|
|
|
|
|
|
|
|
|
|
|
if (host) {
|
2026-10-11 10:25:02 -04:00
|
|
|
|
pollTick();
|
2026-10-07 14:00:37 -04:00
|
|
|
|
log(`following ProPresenter at ${host}:${port}`);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
log('no ProPresenter host configured — set one in /admin');
|
|
|
|
|
|
}
|
2026-10-11 10:25:02 -04:00
|
|
|
|
if (configured) {
|
|
|
|
|
|
loadKeywords();
|
2026-10-07 14:00:37 -04:00
|
|
|
|
log(`Overflow at ${overflow.base}`);
|
|
|
|
|
|
} else {
|
2026-10-11 10:25:02 -04:00
|
|
|
|
log('Overflow login not set — add the email and password in /admin');
|
2026-10-07 14:00:37 -04:00
|
|
|
|
}
|
|
|
|
|
|
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,
|
2026-10-11 10:25:02 -04:00
|
|
|
|
configured,
|
2026-10-07 14:00:37 -04:00
|
|
|
|
getState: () => state,
|
2026-10-11 10:25:02 -04:00
|
|
|
|
refresh: () => loadKeywords(),
|
2026-10-07 14:00:37 -04:00
|
|
|
|
};
|
|
|
|
|
|
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() {
|
2026-10-11 10:25:02 -04:00
|
|
|
|
const [status, message] = assess(true);
|
2026-10-07 14:00:37 -04:00
|
|
|
|
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.' });
|
2026-10-11 10:25:02 -04:00
|
|
|
|
if (!current.configured) return json(res, 400, { error: 'Overflow login is not set.' });
|
2026-10-07 14:00:37 -04:00
|
|
|
|
await current.refresh();
|
|
|
|
|
|
const st = current.getState();
|
|
|
|
|
|
if (!st.overflow.reachable) return json(res, 502, { error: st.overflow.error || 'Overflow unreachable.' });
|
2026-10-11 10:25:02 -04:00
|
|
|
|
json(res, 200, { ok: true, keywords: st.overflow.keywords });
|
2026-10-07 14:00:37 -04:00
|
|
|
|
},
|
|
|
|
|
|
|
2026-10-11 10:25:02 -04:00
|
|
|
|
'GET /keywords': (req, res) => {
|
|
|
|
|
|
if (!current) return json(res, 503, { error: 'Overflow Tap is not running.' });
|
|
|
|
|
|
const st = current.getState();
|
|
|
|
|
|
json(res, 200, { keywords: st.keywords, loadedAt: st.overflow.loadedAt, error: st.overflow.error });
|
2026-10-07 14:00:37 -04:00
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|