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
+23 -21
View File
@@ -45,8 +45,8 @@ export default function create({ root, moduleApi }) {
const pp = state.propresenter || {};
const of = state.overflow || {};
if (!pp.configured) return 'No ProPresenter host — set one in /admin';
if (!of.configured) return 'No Overflow API key — add it in /admin';
if (!(state.keywords || []).length) return 'No keywords — add them in /admin';
if (!of.configured) return 'No Overflow login — add it in /admin';
if (of.reachable && !(state.keywords || []).length) return 'No keyword mappings in the Overflow Dashboard yet';
return '';
}
@@ -78,12 +78,11 @@ export default function create({ root, moduleApi }) {
if (cur) {
// The label carries the state and, when there is one, the moment it
// became true; the small lines under the destination are the groups
// moved — or, when the switch failed or was undone in Overflow, what
// went wrong, in their place (a tile has room for one or the other).
// moved — or, when the switch failed, what went wrong, in their place
// (a tile has room for one or the other).
let label = `TAP IS ON · since ${clock(cur.at)}`;
let tone = 'ok';
let note = '';
let noteClass = 'ot-error';
if (cur.pending) {
label = cur.attempts > 1 ? `SWITCHING · attempt ${cur.attempts}` : 'SWITCHING';
tone = 'muted';
@@ -91,17 +90,12 @@ export default function create({ root, moduleApi }) {
label = `NOT SWITCHED · ${clock(cur.at)}`;
tone = 'alert';
note = cur.error || '';
} else if (cur.drift) {
label = `TAP WAS ON · since ${clock(cur.at)}`;
tone = 'warn';
note = cur.drift;
noteClass = 'ot-error is-warn';
}
main.dataset.tone = tone;
main.append(el('div', 'ot-label', label));
main.append(el('div', 'ot-keyword', cur.keyword));
if (on('showDestination')) main.append(el('div', 'ot-dest', cur.destination ? cur.destination.name : ''));
if (note) main.append(el('div', noteClass, note));
if (on('showDestination')) main.append(el('div', 'ot-dest', cur.destination || ''));
if (note) main.append(el('div', 'ot-error', note));
else if (on('showGroups') && cur.groups && cur.groups.length) main.append(el('div', 'ot-groups', cur.groups.join(' · ')));
} else {
main.dataset.tone = 'muted';
@@ -113,13 +107,21 @@ export default function create({ root, moduleApi }) {
if (on('showSlide')) {
const slide = state.slide || {};
const ann = state.announcement || {};
const slideKeys = Array.isArray(slide.keywords) ? slide.keywords : [];
const annKeys = Array.isArray(ann.keywords) ? ann.keywords : [];
let text;
let cls = 'ot-slide';
if (!pp.reachable) { text = 'ProPresenter: not answering'; cls += ' is-muted'; }
else if (!slide.live) { text = 'Nothing live in ProPresenter'; cls += ' is-muted'; }
else if (slide.unmapped) { text = `This slide: ${slide.keyword} — ${slide.unmapped}`; cls += ' is-warn'; }
else if (slide.keyword) { text = `This slide: ${slide.keyword}`; cls += ' is-hit'; }
else { text = slide.hasNotes ? 'This slide: no keyword' : 'This slide: no notes'; cls += ' is-muted'; }
else if (!slide.live && !ann.live) { text = 'Nothing live in ProPresenter'; cls += ' is-muted'; }
else if (slideKeys.length || annKeys.length) {
const parts = [];
if (slideKeys.length) parts.push(`This slide: ${slideKeys.join(' + ')}`);
if (annKeys.length) parts.push(`Announcement: ${annKeys.join(' + ')}`);
text = parts.join(' · ');
cls += ' is-hit';
} else if (slide.live) { text = slide.hasNotes ? 'This slide: no keyword' : 'This slide: no notes'; cls += ' is-muted'; }
else { text = 'Announcement: no keyword'; cls += ' is-muted'; }
box.append(el('div', cls, text));
}
@@ -132,11 +134,11 @@ export default function create({ root, moduleApi }) {
for (const h of rows) {
const row = el('li', `ot-h-row${h.ok ? '' : ' is-failed'}`);
row.title = h.ok
? `${h.keyword} → ${h.destination ? h.destination.name : ''}${h.groups && h.groups.length ? ` on ${h.groups.join(', ')}` : ''}`
? `${h.keyword} → ${h.destination || ''}${h.groups && h.groups.length ? ` on ${h.groups.join(', ')}` : ''}`
: `${h.keyword}: ${h.error || 'failed'}`;
row.append(el('span', 'ot-h-time', clock(h.at)));
row.append(el('span', 'ot-h-kw', h.keyword));
row.append(el('span', 'ot-h-dest', h.destination ? h.destination.name : ''));
row.append(el('span', 'ot-h-dest', h.destination || ''));
row.append(el('span', 'ot-h-mark', h.ok ? '✓' : '✕'));
list.append(row);
}
@@ -182,12 +184,12 @@ export default function create({ root, moduleApi }) {
try {
const res = await moduleApi.fetch('/refresh', { method: 'POST' });
if (!res.ok) {
let why = `Overflow refresh failed (${res.status})`;
let why = `Keyword refresh failed (${res.status})`;
try { why = (await res.json()).error || why; } catch { /* keep the status */ }
moduleApi.setStatus('error', why);
}
} catch (err) {
moduleApi.setStatus('error', `Overflow refresh failed: ${err && err.message ? err.message : err}`);
moduleApi.setStatus('error', `Keyword refresh failed: ${err && err.message ? err.message : err}`);
} finally {
refreshBtn.classList.remove('active');
}
@@ -198,7 +200,7 @@ export default function create({ root, moduleApi }) {
moduleApi.setStatus('connecting', 'Connecting');
refreshBtn = moduleApi.header.addButton({
icon: ICON_REFRESH,
title: 'Reload Overflow',
title: 'Refresh keywords',
onClick: refresh,
});
stream = moduleApi.sse('/stream', {