Overflow Tap: speak Overflow's Dashboard API, as its own helper app does

The first cut guessed a write route on Overflow's public API, which documents
only reads for Tap. With the helper app's bundle in hand (Overflow ProPresenter
Integration 1.2.3, read 2026-10-11) the module now makes exactly the calls the
helper makes, against the Dashboard's own backend:

  POST /nonprofit-dashboard/authentication/login/jwt      a Dashboard sign-in → JWT
  GET  /nonprofit-dashboard/propresenter/nonprofits/{id}/keywords
                                                          the Keyword Mappings
  POST …/nonprofits/{id}/keywords?syncNonprofit=true      { keywords } = the switch

So the keyword → destination → Tap groups mapping stays where Overflow keeps
it (Dashboard › Integrations › ProPresenter › Keyword Mappings) and is read
from there: on start, every five minutes, on the tile's refresh button, and
from every trigger's answer. Admin config shrinks to the ProPresenter endpoint,
the Overflow Dashboard login (plus a Nonprofit ID only for an @overflow.co
staff login) and the alert switch; the API-key, Tap-groups, keyword-text and
match-mode fields are gone.

Behaviour follows the helper: both the presentation layer (/v1/status/slide)
and the announcement layer (/v1/announcement/slide_index + /active) are
watched; when a layer's set of matched keywords changes to a non-empty set it
is sent; matching is per word, case-insensitive, punctuation around the word
ignored. The session token is cached across re-inits for the same login so an
admin saving config repeatedly cannot trip Overflow's "too many attempts";
a 401 signs in again once; a refused sign-in is not retried within 20 s.

tools/overflow-tap-mock.js now plays the Dashboard API (any email/password
signs in; --fail-switch, --expire=<s>, --login=<email>:<password>) and gives
the mock ProPresenter an announcement layer (--no-announcements for an older
one). Verified against it: sign-in, keyword load, triggers on both layers,
token expiry → re-login, 500 → NOT SWITCHED + alert + retries, wrong login →
the reason on the tile and in the health line, and recovery. keywords.selftest.js
pins the matching rule (8 cases).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Waters Church
2026-10-11 10:25:02 -04:00
co-authored by Claude Fable 5.1
parent 6b51044be6
commit 459d8b9c38
9 changed files with 738 additions and 622 deletions
+310 -214
View File
@@ -4,48 +4,59 @@
* 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.
* 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.
*
* 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
* 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
*
* 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.
* 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.
*
* 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 crypto = require('crypto');
const { createOverflowClient, OverflowError, describeSession } = require('./overflow');
const { matchKeywords, sameSet, cleanNotes, norm } = require('./keywords');
const POLL_MS = 500; // ProPresenter, while it answers
const RETRY_MS = 3000; // ProPresenter, while it does not
const POLL_MS = 1000; // 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 KEYWORDS_REFRESH_MS = 5 * 60 * 1000;
const OVERFLOW_RETRY_MS = 30 * 1000;
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
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;
/**
* 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 }
function json(res, code, body) {
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
res.end(JSON.stringify(body));
@@ -60,20 +71,43 @@ function describe(err) {
return cause && !msg.includes(String(cause)) ? `${msg} (${cause})` : msg;
}
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);
}
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 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);
const alertEnabled = Boolean(config.alertEnabled);
const alertChannel = Math.min(6, Math.max(1, Math.round(Number(config.alertChannel) || 1)));
const sessionKey = crypto.createHash('sha256').update(`${email}\n${ofPassword}`).digest('hex');
const overflow = createOverflowClient({ clientId: config.clientId, apiKey: config.apiKey });
const overflow = createOverflowClient();
/* ── state ─────────────────────────────────────────────────────── */
const streams = new Set();
@@ -81,23 +115,35 @@ module.exports = {
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 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 }
let inFlight = false;
let retriesLeft = 0;
let retryTimer = null;
let alertRaised = false;
// 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 = [];
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 },
overflow: { configured, reachable: false, error: '', keywords: 0, loadedAt: 0 },
keywords: [],
slide: { live: false, keyword: '', unmapped: '', hasNotes: false },
slide: { live: false, keywords: [], hasNotes: false },
announcement: { live: false, keywords: [] },
current: null,
history: [],
updatedAt: 0,
@@ -117,30 +163,26 @@ module.exports = {
/* ── health, shared by the admin line and the tiles ────────────── */
function assess() {
function assess(forAdmin = false) {
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 (!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…'];
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.keywords.length) return ['error', 'No keyword mappings in the Overflow Dashboard (Integrations › ProPresenter)'];
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}`];
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'}`];
}
function publish() {
if (stopped) return;
[state.status, state.message] = assess();
[state.status, state.message] = assess(false);
const { updatedAt, ...rest } = state;
let frame;
try {
@@ -161,103 +203,106 @@ module.exports = {
}
}
/* ── Overflow: destinations, groups, and the keywords resolved ─── */
/* ── the Overflow session ──────────────────────────────────────── */
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'}`);
}
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;
}
cur.drift = moved.length ? `changed in Overflow: ${moved.join('; ')}` : '';
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;
}
let destinationsTimer = null;
let groupsTimer = null;
let loading = null;
function dropSession() {
session = null;
if (sessionCache && sessionCache.key === sessionKey) sessionCache = null;
}
async function loadOverflow(what = 'all') {
if (stopped || !overflow.configured) return;
/** 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;
if (loading) return loading;
loading = (async () => {
try {
if (what === 'all' || what === 'destinations') destinations = await overflow.listDestinations();
groups = await overflow.listGroups();
const list = await withSession((s) => overflow.listKeywords(s.token, s.nonprofitId));
applyKeywordList(list);
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();
reconsider(); // a slide already up when the mappings arrive takes effect now
} 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();
})();
@@ -266,24 +311,28 @@ module.exports = {
} 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;
if (refreshTimer) {
clearTimeout(refreshTimer);
timers.delete(refreshTimer);
}
refreshTimer = later(loadKeywords, state.overflow.reachable ? KEYWORDS_REFRESH_MS : OVERFLOW_RETRY_MS);
}
/* ── the switch ────────────────────────────────────────────────── */
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)),
};
}
function pushHistory(entry) {
const { pending, drift, ...rest } = entry;
const { pending, ...rest } = entry;
state.history.unshift(rest);
if (state.history.length > HISTORY_MAX) state.history.length = HISTORY_MAX;
}
@@ -316,7 +365,7 @@ module.exports = {
if (stopped || inFlight || !wanted) return;
const target = wanted;
const cur = state.current;
const sameTarget = cur && cur.keyword === target.keyword && cur.destination.id === target.destinationId;
const sameTarget = cur && sameSet(cur.keywords, target.keywords);
if (sameTarget && cur.ok) return; // already there
inFlight = true;
let entry;
@@ -326,15 +375,14 @@ module.exports = {
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),
keywords: target.keywords,
destination: target.destination,
groups: target.groups,
at: Date.now(),
ok: false,
pending: true,
error: '',
attempts: 0,
drift: '',
};
state.current = entry;
}
@@ -342,32 +390,23 @@ module.exports = {
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 };
}
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;
}
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(', ')}`);
log(`Tap → ${entry.destination || '?'} (${target.keyword})${entry.groups.length ? ` on ${entry.groups.join(', ')}` : ''}`);
} catch (err) {
if (stopped) return;
entry.ok = false;
entry.error = describe(err);
log(`Tap not switched to ${target.destination} (${target.keyword}): ${entry.error}`);
log(`Tap not switched to ${target.keyword}: ${entry.error}`);
if (retriesLeft > 0) {
retriesLeft -= 1;
retryTimer = later(() => {
@@ -384,11 +423,38 @@ module.exports = {
}
}
/* ── ProPresenter: the live slide ──────────────────────────────── */
/* ── 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 ──────────────────────────────────────────────── */
function ppUrl(target) {
const u = new URL(`http://${host}:${port}${target}`);
if (password) u.searchParams.set('password', password);
if (ppPassword) u.searchParams.set('password', ppPassword);
return u;
}
@@ -397,7 +463,11 @@ module.exports = {
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}`);
if (!res.ok) {
const err = new Error(`ProPresenter ${res.status} ${res.statusText}`);
err.status = res.status;
throw err;
}
const text = await res.text();
return text ? JSON.parse(text) : null;
} finally {
@@ -405,62 +475,96 @@ module.exports = {
}
}
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);
/** 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;
}
async function pollSlide() {
/**
* 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() {
if (stopped) return;
let ok = false;
let changed = false;
try {
const data = await ppJson('/v1/status/slide');
const cur = data && data.current && typeof data.current === 'object' ? data.current : null;
changed = await pollSlide();
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();
try {
if (await pollAnnouncement()) changed = true;
} catch {
/* the presentation poll already said ProPresenter answers; a hiccup here waits for the next tick */
}
} catch (err) {
state.propresenter.reachable = false;
state.propresenter.error = err && err.name === 'AbortError' ? 'timed out' : describe(err);
}
if (changed) reconsider();
publish();
later(pollSlide, ok ? POLL_MS : RETRY_MS);
later(pollTick, ok ? POLL_MS : RETRY_MS);
}
/* ── go ────────────────────────────────────────────────────────── */
resolveKeywords();
if (host) {
pollSlide();
pollTick();
log(`following ProPresenter at ${host}:${port}`);
} else {
log('no ProPresenter host configured — set one in /admin');
}
if (overflow.configured) {
loadOverflow('all');
if (configured) {
loadKeywords();
log(`Overflow at ${overflow.base}`);
} else {
log('Overflow client ID / API key not set — add them in /admin');
log('Overflow login not set — add the email and password in /admin');
}
publish();
@@ -477,11 +581,9 @@ module.exports = {
const handle = {
streams,
configured,
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,
refresh: () => loadKeywords(),
};
current = handle;
@@ -499,7 +601,7 @@ module.exports = {
if (current === handle) current = null;
},
health() {
const [status, message] = assess();
const [status, message] = assess(true);
return { status, message };
},
};
@@ -529,23 +631,17 @@ module.exports = {
'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.' });
if (!current.configured) return json(res, 400, { error: 'Overflow login is 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 });
json(res, 200, { ok: true, keywords: st.overflow.keywords });
},
'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() });
'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 });
},
};
},