Overflow Tap 1.0.0: switch Overflow Tap destinations from keywords in ProPresenter slide notes #47

Open
michael wants to merge 2 commits from claude/overflow-tap-module into main
9 changed files with 738 additions and 622 deletions
Showing only changes of commit 459d8b9c38 - Show all commits
+1 -1
View File
@@ -52,7 +52,7 @@ title-bar controls). The rules that matter most:
`node tools/slack-mock.js 24716` (Slack; run ProdDash with `SLACK_API_BASE=http://127.0.0.1:24716/api`). `node tools/slack-mock.js 24716` (Slack; run ProdDash with `SLACK_API_BASE=http://127.0.0.1:24716/api`).
`node tools/pco-band-mock.js 24710` (Planning Center for Band Lineup; run ProdDash with `BAND_LINEUP_API_BASE=http://127.0.0.1:24710`). `node tools/pco-band-mock.js 24710` (Planning Center for Band Lineup; run ProdDash with `BAND_LINEUP_API_BASE=http://127.0.0.1:24710`).
`node tools/ableset-mock.js 39071` (AbleSet over OSC/UDP; point the AbleSet module at `127.0.0.1:39071`; `--stopped`, `--no-live`, `--speed 4`). `node tools/ableset-mock.js 39071` (AbleSet over OSC/UDP; point the AbleSet module at `127.0.0.1:39071`; `--stopped`, `--no-live`, `--speed 4`).
`node tools/overflow-tap-mock.js` (ProPresenter slide notes on `127.0.0.1:1602` + the Overflow Tap API on `127.0.0.1:24720`; run ProdDash with `OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720`; `--read-only` fails every switch). `node tools/overflow-tap-mock.js` (ProPresenter slide notes on `127.0.0.1:1602` + the Overflow Dashboard API on `127.0.0.1:24720`; run ProdDash with `OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720`, any email/password signs in; `--fail-switch`, `--expire=20`, `--login=<email>:<password>`).
## Reference code (read-only forks — don't develop these here) ## Reference code (read-only forks — don't develop these here)
+28 -24
View File
@@ -510,26 +510,27 @@ below. Nothing is installed without someone clicking.
read from the account), and a folded **Timing** group for the poll read from the account), and a folded **Timing** group for the poll
intervals and a position trim. intervals and a position trim.
Tile settings: cue name, previous cue, playhead, cue list. Tile settings: cue name, previous cue, playhead, cue list.
- **Overflow Tap** — what Overflow's own ProPresenter helper app does, - **Overflow Tap** — what Overflow's own "ProPresenter Integration" helper
inside ProdDash: the live slide's notes are read from ProPresenter, and app does, inside ProdDash, speaking the same Dashboard API it does: the
when they carry one of the admin page's keywords, the chosen Overflow Tap live slide's notes are read from ProPresenter (the presentation layer and
groups are pointed at that keyword's destination — so a tap on a disc the announcement layer), and when they carry one of the keywords mapped in
follows what is on screen. A slide with no keyword changes nothing. The the Overflow Dashboard, Overflow is told — and points that keyword's Tap
tile is one keyword, big: what Tap is on (and since when), its destination groups at its destination, so a tap on a disc follows what is on screen.
and the groups moved, a chip saying whether the slide on screen carries a A slide with no keyword changes nothing. Keywords match as the helper
keyword, and the last four switches with their times; a failed switch matches them: as a whole word of the notes, any case, punctuation around
turns the tile red with the reason and, if switched on in admin, flashes it ignored (`give` fires on "Give!" and not on "forgiveness"). The tile is
an alert channel. Admin config: the ProPresenter endpoint, the Overflow one keyword, big: what Tap is on (and since when), its destination and the
API credentials (Developers › API Keys in the Overflow Dashboard), the Tap groups moved, a chip saying whether the slide on screen carries a keyword,
groups to switch (read from the account), the keywords — one per line as and the last four switches with their times; a failed switch turns the
`give = Give Online`, optionally `@ Lobby, Kids Wing` to switch only tile red with the reason, is retried, and — if switched on in admin —
those groups — and how a keyword matches (whole word, anywhere, or on its flashes an alert channel. Admin config: the ProPresenter endpoint and an
own line). Tile settings are four switches: destination, groups, slide **Overflow Dashboard login** (email and password; Google sign-in has no
match, history. **One thing is still unverified:** Overflow's public API password, so set one with "Forgot Your Password?"). The keywords, their
documents only *reads* for Tap, so the call that moves a group (`PATCH destinations and their Tap groups stay where Overflow keeps them —
/api/v3/tap/groups/{id}` with the new `destinationId`) is the natural Integrations › ProPresenter › Keyword Mappings in the Dashboard — and are
shape, not a documented one; every switch is confirmed by reading the read from there on start, every few minutes, and on the tile's refresh
group back, so a refusal shows on the tile rather than passing silently. button. Tile settings are four switches: destination, groups, slide
match, history.
- **Band Lineup** — the backstage stage board: everyone on stage in their mic - **Band Lineup** — the backstage stage board: everyone on stage in their mic
slot with photo, name and mic/pack label, on one shared board that every slot with photo, name and mic/pack label, on one shared board that every
screen sees the same way — whoever edits it changes it everywhere, over SSE. screen sees the same way — whoever edits it changes it everywhere, over SSE.
@@ -582,10 +583,13 @@ run ProdDash with `SLACK_API_BASE=http://127.0.0.1:24716/api`) and
board — a plan, its team members, birthdays and photos — run ProdDash with board — a plan, its team members, birthdays and photos — run ProdDash with
`BAND_LINEUP_API_BASE=http://127.0.0.1:24710`) and `BAND_LINEUP_API_BASE=http://127.0.0.1:24710`) and
`node tools/overflow-tap-mock.js` (a ProPresenter whose slide notes carry `node tools/overflow-tap-mock.js` (a ProPresenter whose slide notes carry
keywords on `127.0.0.1:1602` plus the Overflow Tap API on `127.0.0.1:24720` keywords on `127.0.0.1:1602`, announcement layer included, plus the Overflow
— run ProdDash with `OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720`; Dashboard API on `127.0.0.1:24720` — run ProdDash with
`--read-only` makes the switch fail the way the public API does today). See `OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720` and sign the module in with
the testing section of the [module guide](docs/MODULE-GUIDE.md). any email and password; `--fail-switch` refuses every switch,
`--expire=20` makes sessions expire, `--login=<email>:<password>` accepts
one login only). See the testing section of the
[module guide](docs/MODULE-GUIDE.md).
## Install as a desktop web app ## Install as a desktop web app
+23 -21
View File
@@ -45,8 +45,8 @@ export default function create({ root, moduleApi }) {
const pp = state.propresenter || {}; const pp = state.propresenter || {};
const of = state.overflow || {}; const of = state.overflow || {};
if (!pp.configured) return 'No ProPresenter host — set one in /admin'; if (!pp.configured) return 'No ProPresenter host — set one in /admin';
if (!of.configured) return 'No Overflow API key — add it in /admin'; if (!of.configured) return 'No Overflow login — add it in /admin';
if (!(state.keywords || []).length) return 'No keywords — add them in /admin'; if (of.reachable && !(state.keywords || []).length) return 'No keyword mappings in the Overflow Dashboard yet';
return ''; return '';
} }
@@ -78,12 +78,11 @@ export default function create({ root, moduleApi }) {
if (cur) { if (cur) {
// The label carries the state and, when there is one, the moment it // The label carries the state and, when there is one, the moment it
// became true; the small lines under the destination are the groups // became true; the small lines under the destination are the groups
// moved — or, when the switch failed or was undone in Overflow, what // moved — or, when the switch failed, what went wrong, in their place
// went wrong, in their place (a tile has room for one or the other). // (a tile has room for one or the other).
let label = `TAP IS ON · since ${clock(cur.at)}`; let label = `TAP IS ON · since ${clock(cur.at)}`;
let tone = 'ok'; let tone = 'ok';
let note = ''; let note = '';
let noteClass = 'ot-error';
if (cur.pending) { if (cur.pending) {
label = cur.attempts > 1 ? `SWITCHING · attempt ${cur.attempts}` : 'SWITCHING'; label = cur.attempts > 1 ? `SWITCHING · attempt ${cur.attempts}` : 'SWITCHING';
tone = 'muted'; tone = 'muted';
@@ -91,17 +90,12 @@ export default function create({ root, moduleApi }) {
label = `NOT SWITCHED · ${clock(cur.at)}`; label = `NOT SWITCHED · ${clock(cur.at)}`;
tone = 'alert'; tone = 'alert';
note = cur.error || ''; 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.dataset.tone = tone;
main.append(el('div', 'ot-label', label)); main.append(el('div', 'ot-label', label));
main.append(el('div', 'ot-keyword', cur.keyword)); main.append(el('div', 'ot-keyword', cur.keyword));
if (on('showDestination')) main.append(el('div', 'ot-dest', cur.destination ? cur.destination.name : '')); if (on('showDestination')) main.append(el('div', 'ot-dest', cur.destination || ''));
if (note) main.append(el('div', noteClass, note)); 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 if (on('showGroups') && cur.groups && cur.groups.length) main.append(el('div', 'ot-groups', cur.groups.join(' · ')));
} else { } else {
main.dataset.tone = 'muted'; main.dataset.tone = 'muted';
@@ -113,13 +107,21 @@ export default function create({ root, moduleApi }) {
if (on('showSlide')) { if (on('showSlide')) {
const slide = state.slide || {}; 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 text;
let cls = 'ot-slide'; let cls = 'ot-slide';
if (!pp.reachable) { text = 'ProPresenter: not answering'; cls += ' is-muted'; } 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.live && !ann.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 (slideKeys.length || annKeys.length) {
else if (slide.keyword) { text = `This slide: ${slide.keyword}`; cls += ' is-hit'; } const parts = [];
else { text = slide.hasNotes ? 'This slide: no keyword' : 'This slide: no notes'; cls += ' is-muted'; } 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)); box.append(el('div', cls, text));
} }
@@ -132,11 +134,11 @@ export default function create({ root, moduleApi }) {
for (const h of rows) { for (const h of rows) {
const row = el('li', `ot-h-row${h.ok ? '' : ' is-failed'}`); const row = el('li', `ot-h-row${h.ok ? '' : ' is-failed'}`);
row.title = h.ok 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'}`; : `${h.keyword}: ${h.error || 'failed'}`;
row.append(el('span', 'ot-h-time', clock(h.at))); 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-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 ? '✓' : '✕')); row.append(el('span', 'ot-h-mark', h.ok ? '✓' : '✕'));
list.append(row); list.append(row);
} }
@@ -182,12 +184,12 @@ export default function create({ root, moduleApi }) {
try { try {
const res = await moduleApi.fetch('/refresh', { method: 'POST' }); const res = await moduleApi.fetch('/refresh', { method: 'POST' });
if (!res.ok) { 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 */ } try { why = (await res.json()).error || why; } catch { /* keep the status */ }
moduleApi.setStatus('error', why); moduleApi.setStatus('error', why);
} }
} catch (err) { } 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 { } finally {
refreshBtn.classList.remove('active'); refreshBtn.classList.remove('active');
} }
@@ -198,7 +200,7 @@ export default function create({ root, moduleApi }) {
moduleApi.setStatus('connecting', 'Connecting'); moduleApi.setStatus('connecting', 'Connecting');
refreshBtn = moduleApi.header.addButton({ refreshBtn = moduleApi.header.addButton({
icon: ICON_REFRESH, icon: ICON_REFRESH,
title: 'Reload Overflow', title: 'Refresh keywords',
onClick: refresh, onClick: refresh,
}); });
stream = moduleApi.sse('/stream', { stream = moduleApi.sse('/stream', {
+32 -67
View File
@@ -1,27 +1,21 @@
'use strict'; 'use strict';
/** /**
* Keyword mappings — reading the admin page's Keywords text and deciding * Keyword matching — which of Overflow's keywords a slide's notes carry.
* which keyword a slide's notes carry.
* *
* Pure functions, no I/O, so keywords.selftest.js can pin every rule down: * Pure functions, no I/O, so keywords.selftest.js can pin every rule down:
* *
* parseKeywordLines(text) → [{ line, keyword, destination, groups, problem }] * cleanNotes(raw) → the notes with ProPresenter's invisible padding removed
* keywordMatches(notes, keyword, mode) → boolean * keywordMatches(notes, keyword) → boolean
* matchKeyword(notes, mappings, mode) → the first mapping whose keyword the notes carry, or null * matchKeywords(notes, keywords) → every keyword (in the order given) the notes carry
* cleanNotes(raw) → the notes with ProPresenter's invisible padding removed * sameSet(a, b) → the two keyword lists hold the same keywords
* *
* The Keywords text is one mapping per line: * The rule is Overflow's own helper's: the notes are split into whitespace-
* * separated words, each stripped of surrounding punctuation and lower-cased,
* give = Give Online * and a keyword fires when it is one of those words. So `give` fires on
* connect -> Connect Card @ Lobby, Kids Wing * "give", "Give!", "#give" and "(give)", and not on "forgiveness", "giveaway"
* # a comment * or "give_now". Phrases work too, bounded the same way on both ends —
* * something the helper cannot do, but nothing it would have matched is lost.
* `=`, `->` and `→` all separate the keyword from the destination. A trailing
* ` @ ` names the Tap groups this line switches (by name or id); without it the
* admin page's chosen groups apply. The destination is the name Overflow shows
* for it, or its id — resolving that against the account is the server's job,
* so a parse never needs the network.
*/ */
const ZERO_WIDTH = /[​-‍⁠]/g; const ZERO_WIDTH = /[​-‍⁠]/g;
@@ -34,68 +28,39 @@ function norm(s) {
return String(s ?? '').trim().toLowerCase().replace(/\s+/g, ' '); return String(s ?? '').trim().toLowerCase().replace(/\s+/g, ' ');
} }
function parseKeywordLines(text) {
const out = [];
const seen = new Set();
cleanNotes(text).split('\n').forEach((raw, i) => {
const line = raw.trim();
if (!line || line.startsWith('#')) return;
const m = /^(.*?)\s*(?:=|->|→)\s*(.*)$/.exec(line);
const entry = { line: i + 1, keyword: '', destination: '', groups: [], problem: '' };
if (!m) {
entry.keyword = line;
entry.problem = 'No destination — write it as "keyword = Destination name"';
out.push(entry);
return;
}
entry.keyword = m[1].trim();
let rest = m[2].trim();
const at = rest.indexOf('@');
if (at >= 0) {
entry.groups = rest.slice(at + 1).split(',').map((s) => s.trim()).filter(Boolean);
rest = rest.slice(0, at).trim();
}
entry.destination = rest;
if (!entry.keyword) entry.problem = 'No keyword before the =';
else if (!entry.destination) entry.problem = 'No destination after the =';
else if (seen.has(norm(entry.keyword))) entry.problem = 'Repeated keyword — the first line wins';
if (entry.keyword) seen.add(norm(entry.keyword));
out.push(entry);
});
return out;
}
function escapeRe(s) { function escapeRe(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
} }
/** function keywordMatches(notes, keyword) {
* Does this slide's notes carry the keyword?
* word — the keyword (a word or a phrase) bounded by the start/end of the
* notes or by something that is not a letter, digit or underscore.
* `give` fires on "give" and "give!" but not on "forgiveness".
* anywhere — plain substring, what Overflow's own helper does.
* line — a line of the notes is exactly the keyword.
* All three ignore case and collapse runs of whitespace inside a phrase.
*/
function keywordMatches(notes, keyword, mode = 'word') {
const n = cleanNotes(notes); const n = cleanNotes(notes);
const k = norm(keyword); const k = norm(keyword);
if (!k || !n.trim()) return false; if (!k || !n.trim()) return false;
if (mode === 'line') return n.split('\n').some((l) => norm(l) === k);
const flat = n.toLowerCase().replace(/\s+/g, ' '); const flat = n.toLowerCase().replace(/\s+/g, ' ');
if (mode === 'anywhere') return flat.includes(k);
const re = new RegExp(`(?:^|[^\\p{L}\\p{N}_])${escapeRe(k)}(?=$|[^\\p{L}\\p{N}_])`, 'u'); const re = new RegExp(`(?:^|[^\\p{L}\\p{N}_])${escapeRe(k)}(?=$|[^\\p{L}\\p{N}_])`, 'u');
return re.test(flat); return re.test(flat);
} }
/** The first mapping (in admin order) whose keyword the notes carry, or null. */ /** Every keyword the notes carry, in the order the list gives them, each once. Entries may be strings or objects with a `keyword`. */
function matchKeyword(notes, mappings, mode = 'word') { function matchKeywords(notes, keywords) {
for (const m of mappings || []) { const out = [];
if (!m || !m.keyword) continue; const seen = new Set();
if (keywordMatches(notes, m.keyword, mode)) return m; for (const raw of keywords || []) {
const k = typeof raw === 'string' ? raw : raw && raw.keyword;
const key = norm(k);
if (!key || seen.has(key)) continue;
if (keywordMatches(notes, k)) {
seen.add(key);
out.push(String(k).trim());
}
} }
return null; return out;
} }
module.exports = { cleanNotes, norm, parseKeywordLines, keywordMatches, matchKeyword }; function sameSet(a, b) {
const x = [...(a || [])].map(norm).sort();
const y = [...(b || [])].map(norm).sort();
return x.length === y.length && x.every((v, i) => v === y[i]);
}
module.exports = { cleanNotes, norm, keywordMatches, matchKeywords, sameSet };
+34 -73
View File
@@ -1,13 +1,13 @@
'use strict'; 'use strict';
/** /**
* Self-test for keywords.js — the parsing and matching rules, pinned. * Self-test for keywords.js — the matching rules, pinned.
* *
* node modules/overflow-tap/keywords.selftest.js * node modules/overflow-tap/keywords.selftest.js
*/ */
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const { parseKeywordLines, keywordMatches, matchKeyword, cleanNotes } = require('./keywords'); const { keywordMatches, matchKeywords, sameSet, cleanNotes } = require('./keywords');
let n = 0; let n = 0;
function test(name, fn) { function test(name, fn) {
@@ -21,95 +21,56 @@ function test(name, fn) {
} }
} }
test('parses keyword = destination, with -> and → too', () => { test('a keyword fires as a word of the notes, whatever the case', () => {
const m = parseKeywordLines('give = Give Online\nconnect -> Connect Card\nprayer → Prayer Request'); assert.equal(keywordMatches('give', 'give'), true);
assert.deepEqual(m.map((x) => [x.keyword, x.destination, x.problem]), [ assert.equal(keywordMatches('Give', 'give'), true);
['give', 'Give Online', ''], assert.equal(keywordMatches('please GIVE today', 'give'), true);
['connect', 'Connect Card', ''],
['prayer', 'Prayer Request', ''],
]);
}); });
test('a trailing @ names the groups for that line', () => { test('surrounding punctuation is ignored, as the helper strips it', () => {
const [m] = parseKeywordLines('connect = Connect Card @ Lobby, Kids Wing'); assert.equal(keywordMatches('give!', 'give'), true);
assert.equal(m.destination, 'Connect Card'); assert.equal(keywordMatches('#give now', 'give'), true);
assert.deepEqual(m.groups, ['Lobby', 'Kids Wing']); assert.equal(keywordMatches('go to (give) now', 'give'), true);
assert.equal(keywordMatches('"give"', 'give'), true);
assert.equal(keywordMatches('give.', 'give'), true);
}); });
test('blank lines and # comments are skipped, line numbers kept', () => { test('a keyword inside another word does not fire', () => {
const m = parseKeywordLines('\n# giving\n\ngive = Give Online\n'); assert.equal(keywordMatches('a sermon on forgiveness', 'give'), false);
assert.equal(m.length, 1); assert.equal(keywordMatches('giveaway', 'give'), false);
assert.equal(m[0].line, 4); assert.equal(keywordMatches('give_now', 'give'), false);
}); });
test('a line without a separator is a problem, not a crash', () => { test('a phrase keyword is bounded the same way', () => {
const [m] = parseKeywordLines('give'); assert.equal(keywordMatches('Please fill out your Next Steps card', 'next steps'), true);
assert.equal(m.keyword, 'give'); assert.equal(keywordMatches('nextsteps', 'next steps'), false);
assert.match(m.problem, /No destination/);
});
test('missing keyword or destination is reported', () => {
const [a, b] = parseKeywordLines('= Give Online\ngive =');
assert.match(a.problem, /No keyword/);
assert.match(b.problem, /No destination/);
});
test('a repeated keyword is flagged and the first line wins', () => {
const m = parseKeywordLines('give = A\nGIVE = B');
assert.equal(m[0].problem, '');
assert.match(m[1].problem, /Repeated/);
assert.equal(matchKeyword('give', m).destination, 'A');
});
test('keywords and destinations may be phrases', () => {
const [m] = parseKeywordLines('next steps = Next Steps Card');
assert.equal(m.keyword, 'next steps');
assert.equal(keywordMatches('Please fill out your Next Steps card', 'next steps', 'word'), true);
});
test('whole word: give does not fire on forgiveness, does on "give!"', () => {
assert.equal(keywordMatches('a sermon on forgiveness', 'give', 'word'), false);
assert.equal(keywordMatches('give!', 'give', 'word'), true);
assert.equal(keywordMatches('Give', 'give', 'word'), true);
assert.equal(keywordMatches('#give now', 'give', 'word'), true);
assert.equal(keywordMatches('giveaway', 'give', 'word'), false);
assert.equal(keywordMatches('give_now', 'give', 'word'), false);
});
test('anywhere: plain substring, like the Overflow helper', () => {
assert.equal(keywordMatches('a sermon on forgiveness', 'give', 'anywhere'), true);
assert.equal(keywordMatches('nothing here', 'give', 'anywhere'), false);
});
test('line: the keyword on its own line only', () => {
assert.equal(keywordMatches('speaker note\ngive\nmore', 'give', 'line'), true);
assert.equal(keywordMatches('give now', 'give', 'line'), false);
assert.equal(keywordMatches(' Give ', 'give', 'line'), true);
}); });
test('regex characters in a keyword are literal', () => { test('regex characters in a keyword are literal', () => {
assert.equal(keywordMatches('go to (give) now', '(give)', 'word'), true); assert.equal(keywordMatches('give.now', 'give.now'), true);
assert.equal(keywordMatches('give.now', 'give.now', 'anywhere'), true); assert.equal(keywordMatches('giveXnow', 'give.now'), false);
assert.equal(keywordMatches('giveXnow', 'give.now', 'anywhere'), false);
}); });
test('empty notes never match; zero-width padding is ignored', () => { test('empty notes never match; zero-width padding is ignored', () => {
assert.equal(keywordMatches('', 'give'), false); assert.equal(keywordMatches('', 'give'), false);
assert.equal(keywordMatches('​', 'give', 'anywhere'), false); assert.equal(keywordMatches('​', 'give'), false);
assert.equal(cleanNotes('​give'), 'give'); assert.equal(cleanNotes('​give'), 'give');
assert.equal(keywordMatches('​give', 'give', 'line'), true); assert.equal(keywordMatches('​give', 'give'), true);
}); });
test('matchKeyword returns the first mapping in admin order', () => { test('matchKeywords returns every hit, in list order, once each', () => {
const m = parseKeywordLines('connect = Connect Card\ngive = Give Online'); const list = ['connect', 'give', 'prayer', { keyword: 'Events' }];
assert.equal(matchKeyword('give and connect', m).keyword, 'connect'); assert.deepEqual(matchKeywords('give and connect, then events', list), ['connect', 'give', 'Events']);
assert.equal(matchKeyword('just worship', m), null); assert.deepEqual(matchKeywords('just worship', list), []);
assert.equal(matchKeyword('give', []), null); assert.deepEqual(matchKeywords('give', []), []);
assert.deepEqual(matchKeywords('give give', ['give', 'GIVE']), ['give']);
}); });
test('a mapping with a parse problem can still be the match (the caller decides)', () => { test('sameSet compares keyword lists regardless of order and case', () => {
const m = parseKeywordLines('baptism'); assert.equal(sameSet(['give', 'connect'], ['Connect', 'give']), true);
assert.equal(matchKeyword('baptism', m).problem.length > 0, true); assert.equal(sameSet(['give'], ['give', 'connect']), false);
assert.equal(sameSet([], []), true);
assert.equal(sameSet(['give'], []), false);
}); });
console.log(`${n} tests, exit ${process.exitCode || 0}`); console.log(`${n} tests, exit ${process.exitCode || 0}`);
+10 -42
View File
@@ -23,14 +23,11 @@
}, },
"configGroups": { "configGroups": {
"ProPresenter": { "ProPresenter": {
"help": "The ProPresenter whose slide notes carry the keywords. Enable the network in ProPresenter's Preferences › Network and enter the host and port shown there. Only the live slide's notes are read — nothing on the stage screens changes." "help": "The ProPresenter whose slide notes carry the keywords. Enable the network in ProPresenter's Preferences › Network and enter the host and port shown there. Only the live slide's notes (and the announcement layer's) are read — nothing on the stage screens changes."
}, },
"Overflow": { "Overflow": {
"collapsed": "whenSet", "collapsed": "whenSet",
"help": "API credentials from the Overflow Dashboard: Developers › API Keys (a READ_WRITE key). Tap groups and destinations are read from the account once these are saved; the groups list below fills in from there." "help": "An Overflow Dashboard login with Tap access — what Overflow's own ProPresenter helper app signs in with. Google sign-in does not work here: the account needs a password (Forgot Your Password? on the Dashboard sets one). The keywords, their destinations and their Tap groups are managed in the Dashboard under Integrations › ProPresenter › Keyword Mappings and read from there; after changing them, press the tile's refresh button or wait a few minutes."
},
"Keywords": {
"help": "One keyword per line, as `keyword = Destination name` — the destination exactly as it is named in Overflow's Tap module (or its id). Add ` @ Group A, Group B` to a line to switch only those groups instead of the ones chosen above. When a slide's notes carry one of these keywords, every chosen group is pointed at that destination; a slide without a keyword changes nothing. When several match, the first line wins."
}, },
"Alerts": { "Alerts": {
"toggle": "alertEnabled", "toggle": "alertEnabled",
@@ -55,53 +52,24 @@
"default": "", "default": "",
"help": "Only if a password is set in ProPresenter's network preferences." "help": "Only if a password is set in ProPresenter's network preferences."
}, },
"clientId": { "overflowEmail": {
"type": "string", "type": "string",
"label": "Client ID", "label": "Email",
"group": "Overflow", "group": "Overflow",
"default": "" "default": ""
}, },
"apiKey": { "overflowPassword": {
"type": "password", "type": "password",
"label": "API key", "label": "Password",
"group": "Overflow", "group": "Overflow",
"default": "" "default": ""
}, },
"groups": { "nonprofitId": {
"type": "multiselect", "type": "string",
"label": "Tap groups", "label": "Nonprofit ID",
"group": "Overflow", "group": "Overflow",
"default": [],
"optionsRoute": "/groups",
"help": "The groups a keyword switches. None chosen means every group on the account."
},
"keywords": {
"type": "text",
"label": "Keywords",
"group": "Keywords",
"default": "", "default": "",
"help": "One per line — give = Give Online" "help": "Leave blank: a church login carries its own. Only an @overflow.co staff login needs one."
},
"matching": {
"type": "select",
"label": "Match",
"group": "Keywords",
"default": "word",
"options": [
{
"value": "word",
"label": "Whole word in the notes"
},
{
"value": "anywhere",
"label": "Anywhere in the notes"
},
{
"value": "line",
"label": "On its own line"
}
],
"help": "Whole word keeps `give` from firing on `forgiveness`; anywhere is what Overflow's own helper does."
}, },
"alertEnabled": { "alertEnabled": {
"type": "switch", "type": "switch",
+118 -70
View File
@@ -1,78 +1,104 @@
'use strict'; 'use strict';
/** /**
* Overflow API client — the Tap part of Overflow's public REST API. * Overflow Dashboard API — the calls Overflow's own "ProPresenter Integration"
* helper app (1.2.3) makes, read from its bundle on 2026-10-11. None of this is
* in Overflow's public API reference (which documents only reads for Tap): it
* is the Dashboard's own backend, used with a Dashboard login — the same
* email and password the helper asks for. Google sign-in does not work here;
* the account needs a password (Overflow's article says the same).
* *
* https://docs.overflow.co/api-reference (OpenAPI: server.stage.overflow.co/api/docs/openapi.json) * POST /nonprofit-dashboard/authentication/login/jwt
* { email, password } → { token } a JWT; its payload carries
* email, nonprofitId, permissions [{ action, subject }] (subject TAP
* or all is required), nonprofits [...], exp
* GET /nonprofit-dashboard/propresenter/nonprofits/{nonprofitId}/keywords
* → [ { keyword, destinationName, tapGroups: [{ id, tapGroupName }], … } ]
* the Dashboard's Integrations › ProPresenter › Keyword Mappings
* POST /nonprofit-dashboard/propresenter/nonprofits/{nonprofitId}/keywords?syncNonprofit=true
* { keywords: ["give"] } → the same list, updated THE SWITCH:
* Overflow points the keyword's groups at its destination
* GET /nonprofit-dashboard/propresenter/nonprofits?search=&limit=
* POST /nonprofit-dashboard/propresenter/nonprofits/{nonprofitId}/select
* only for an @overflow.co staff login, which has no nonprofit of its
* own and must pick one first
* *
* Authentication is two headers, `x-client-id` and `x-api-key`, from the * Bearer auth on everything after login. A 401 means the session is gone —
* Overflow Dashboard's Developers › API Keys. 120 requests per minute per * sign in again. The helper's own wording for a refused login is kept here
* client; a 429 is answered when the window is spent. * (401 invalid email or password, 429 too many attempts, 403 locked).
* *
* Reads used here are documented: GET /api/v3/tap/destinations, * OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720 points the client at
* GET /api/v3/tap/groups and GET /api/v3/tap/groups/{id} (a group carries the * tools/overflow-tap-mock.js.
* `destinationId` it currently points at).
*
* THE SWITCH IS NOT (as of 2026-10-07). The public reference lists reads only
* for Tap; Overflow's own ProPresenter helper changes a group's destination
* through the Dashboard's private API after signing in with a Dashboard
* login. `setGroupDestination` below sends the natural REST shape for this
* API — PATCH the group with the new `destinationId`, as PATCH /donors/{id}
* updates a donor — and the server confirms every switch by reading the group
* back, so an answer that changes nothing is reported, not believed. If
* Overflow publishes (or confirms) a different route, this one function is
* the only place to change. tools/overflow-tap-mock.js serves the same shape.
*
* OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720 points the client at that mock.
*/ */
const DEFAULT_API_BASE = 'https://server.overflow.co'; const DEFAULT_API_BASE = 'https://server.overflow.co';
const API_BASE = String(process.env.OVERFLOW_TAP_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, ''); const API_BASE = String(process.env.OVERFLOW_TAP_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, '');
const DASH = '/nonprofit-dashboard';
const TIMEOUT_MS = 10000; const TIMEOUT_MS = 10000;
const PAGE_SIZE = 100; const TRIGGER_TIMEOUT_MS = 15000;
const MAX_PAGES = 20;
const ID_RE = /^[0-9a-f]{24}$/i;
class OverflowError extends Error { class OverflowError extends Error {
constructor(message, { status = 0, retryable = false } = {}) { constructor(message, { status = 0, retryable = false, auth = false } = {}) {
super(message); super(message);
this.name = 'OverflowError'; this.name = 'OverflowError';
this.status = status; this.status = status;
this.retryable = retryable; this.retryable = retryable;
this.auth = auth; // the session was refused: sign in again before retrying
} }
} }
function looksLikeId(s) { /** The JWT's payload, or null when the token is not one. No signature check — we only read our own session. */
return ID_RE.test(String(s || '').trim()); function decodeJwt(token) {
const parts = String(token || '').split('.');
if (parts.length !== 3) return null;
try {
const b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
return JSON.parse(Buffer.from(b64, 'base64').toString('utf8'));
} catch {
return null;
}
} }
function createOverflowClient({ clientId, apiKey, fetchImpl = globalThis.fetch }) { /** What the module needs to know about a session, from its token. */
const id = String(clientId || '').trim(); function describeSession(token) {
const key = String(apiKey || '').trim(); const p = decodeJwt(token) || {};
const email = String(p.email || '');
const perms = Array.isArray(p.permissions) ? p.permissions : [];
const nonprofits = Array.isArray(p.nonprofits)
? p.nonprofits.map((n) => ({ id: String((n && n.id) || ''), name: String((n && n.name) || '') })).filter((n) => n.id)
: [];
return {
email,
staff: email.toLowerCase().endsWith('@overflow.co'),
nonprofitId: p.nonprofitId ? String(p.nonprofitId) : '',
nonprofits,
hasTap: perms.some((x) => x && (x.subject === 'TAP' || x.subject === 'all')),
expiresAt: Number.isFinite(Number(p.exp)) ? Number(p.exp) * 1000 : 0,
};
}
async function request(method, path, { query, body } = {}) { function createOverflowClient({ fetchImpl = globalThis.fetch } = {}) {
if (!id || !key) throw new OverflowError('Overflow client ID and API key are not set', { status: 0 }); async function request(method, path, { query, body, token, timeout = TIMEOUT_MS } = {}) {
const url = new URL(API_BASE + path); const url = new URL(API_BASE + path);
for (const [k, v] of Object.entries(query || {})) { for (const [k, v] of Object.entries(query || {})) {
if (v === undefined || v === null || v === '') continue; if (v === undefined || v === null || v === '') continue;
url.searchParams.set(k, String(v)); url.searchParams.set(k, String(v));
} }
const headers = { Accept: 'application/json', 'x-client-id': id, 'x-api-key': key }; const headers = { Accept: 'application/json' };
if (token) headers.Authorization = `Bearer ${token}`;
if (body !== undefined) headers['Content-Type'] = 'application/json'; if (body !== undefined) headers['Content-Type'] = 'application/json';
const ctl = new AbortController(); const ctl = new AbortController();
const timer = setTimeout(() => ctl.abort(), TIMEOUT_MS); const timer = setTimeout(() => ctl.abort(), timeout);
let res; let res;
try { try {
res = await fetchImpl(url, { res = await fetchImpl(url, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), signal: ctl.signal });
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
signal: ctl.signal,
});
} catch (err) { } catch (err) {
const aborted = err && err.name === 'AbortError'; const aborted = err && err.name === 'AbortError';
const cause = err && err.cause && (err.cause.code || err.cause.message); const cause = err && err.cause && (err.cause.code || err.cause.message);
throw new OverflowError(aborted ? `Overflow did not answer within ${TIMEOUT_MS / 1000} s` : `Overflow unreachable: ${err.message}${cause ? ` (${cause})` : ''}`, { retryable: true }); throw new OverflowError(
aborted ? `Overflow did not answer within ${timeout / 1000} s` : `Overflow unreachable: ${err.message}${cause ? ` (${cause})` : ''}`,
{ retryable: true },
);
} finally { } finally {
clearTimeout(timer); clearTimeout(timer);
} }
@@ -83,44 +109,66 @@ function createOverflowClient({ clientId, apiKey, fetchImpl = globalThis.fetch }
} }
if (!res.ok) { if (!res.ok) {
const detail = data && (data.message || data.error || data.detail); const detail = data && (data.message || data.error || data.detail);
const what = Array.isArray(detail) ? detail.join('; ') : (detail ? String(detail) : res.statusText); const what = Array.isArray(detail) ? detail.join('; ') : (detail ? String(detail) : '');
let message; throw new OverflowError(what ? `Overflow answered ${res.status}: ${what}` : `Overflow answered ${res.status} ${res.statusText}`.trim(), {
if (res.status === 401 || res.status === 403) message = `Overflow refused the credentials (${res.status}${what ? `: ${what}` : ''})`; status: res.status,
else if (res.status === 404 || res.status === 405) message = `Overflow has no ${method} ${path} (${res.status})`; auth: res.status === 401,
else if (res.status === 429) message = 'Overflow is rate-limiting (120 requests a minute)'; retryable: res.status === 429 || res.status >= 500,
else message = `Overflow answered ${res.status}${what ? `: ${what}` : ''}`; });
throw new OverflowError(message, { status: res.status, retryable: res.status === 429 || res.status >= 500 });
} }
return data; return data;
} }
/** Every page of a collection, newest-first as the API sorts it. */
async function listAll(path, query = {}) {
const all = [];
for (let page = 1; page <= MAX_PAGES; page += 1) {
const res = await request('GET', path, { query: { ...query, limit: PAGE_SIZE, page } });
const data = Array.isArray(res && res.data) ? res.data : [];
all.push(...data);
const total = Number(res && res.totalCount);
if (data.length < PAGE_SIZE || (Number.isFinite(total) && all.length >= total)) break;
}
return all;
}
return { return {
base: API_BASE, base: API_BASE,
configured: Boolean(id && key),
listDestinations: () => listAll('/api/v3/tap/destinations'), /** Sign in with a Dashboard login; resolves to the JWT. */
listGroups: () => listAll('/api/v3/tap/groups'), async login(email, password) {
async getGroup(groupId) { try {
const res = await request('GET', `/api/v3/tap/groups/${encodeURIComponent(groupId)}`); const data = await request('POST', `${DASH}/authentication/login/jwt`, { body: { email, password } });
return res && res.data ? res.data : res; const token = data && data.token;
if (!token || typeof token !== 'string') throw new OverflowError('Overflow sign-in answered without a token');
return token;
} catch (err) {
if (!(err instanceof OverflowError) || !err.status) throw err;
if (err.status === 401) throw new OverflowError('Overflow refused the sign-in: invalid email or password', { status: 401 });
if (err.status === 429) throw new OverflowError('Overflow refused the sign-in: too many attempts — wait a while', { status: 429, retryable: true });
if (err.status === 403) throw new OverflowError('Overflow refused the sign-in: account locked', { status: 403 });
throw err;
}
}, },
/** See the note at the top of this file: the one unverified call. */
setGroupDestination(groupId, destinationId) { /** The Keyword Mappings for a nonprofit, as the Dashboard holds them. */
return request('PATCH', `/api/v3/tap/groups/${encodeURIComponent(groupId)}`, { body: { destinationId } }); async listKeywords(token, nonprofitId) {
const data = await request('GET', `${DASH}/propresenter/nonprofits/${encodeURIComponent(nonprofitId)}/keywords`, { token });
return Array.isArray(data) ? data : [];
},
/** Tell Overflow these keywords are on screen; it moves their groups. Resolves to the updated list when one comes back. */
async trigger(token, nonprofitId, keywords) {
const data = await request('POST', `${DASH}/propresenter/nonprofits/${encodeURIComponent(nonprofitId)}/keywords`, {
query: { syncNonprofit: 'true' },
body: { keywords },
token,
timeout: TRIGGER_TIMEOUT_MS,
});
return Array.isArray(data) ? data : null;
},
/** Staff logins only: the nonprofits this login may act for. */
async searchNonprofits(token, search = '', limit = 10) {
const data = await request('GET', `${DASH}/propresenter/nonprofits`, { token, query: { search, limit } });
const list = (data && data.data && data.data.nonprofits) || (data && data.nonprofits) || (data && data.data) || data;
return Array.isArray(list)
? list.map((n) => ({ id: String((n && n.id) || ''), name: String((n && n.name) || ''), slug: String((n && n.slug) || '') }))
: [];
},
/** Staff logins only: bind the session to one nonprofit before reading its keywords. */
selectNonprofit(token, nonprofitId) {
return request('POST', `${DASH}/propresenter/nonprofits/${encodeURIComponent(nonprofitId)}/select`, { token, body: {} });
}, },
}; };
} }
module.exports = { createOverflowClient, OverflowError, looksLikeId, API_BASE, DEFAULT_API_BASE }; module.exports = { createOverflowClient, OverflowError, decodeJwt, describeSession, API_BASE, DEFAULT_API_BASE };
+310 -214
View File
@@ -4,48 +4,59 @@
* Overflow Tap — server entry. * Overflow Tap — server entry.
* *
* Does what Overflow's "ProPresenter Integration" helper app does, inside * Does what Overflow's "ProPresenter Integration" helper app does, inside
* ProdDash: watches the live slide in ProPresenter, and when its notes carry * ProdDash: watches the live slide in ProPresenter (the presentation layer
* one of the admin page's keywords, points the chosen Overflow Tap groups at * and the announcement layer), and when its notes carry one of the keywords
* that keyword's destination — so what people reach when they tap a disc * mapped in the Overflow Dashboard, tells Overflow — which points that
* follows what is on screen. A slide with no keyword changes nothing; the * keyword's Tap groups at its destination, so what people reach when they
* last destination stays until the next keyworded slide. * 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 /state → the view-model every tile draws
* GET /stream → SSE, `state` events on every change (heartbeated) * GET /stream → SSE, `state` events on every change (heartbeated)
* POST /refresh → re-read Overflow's groups and destinations now * POST /refresh → re-read the keyword mappings from the Dashboard now
* GET /groups → optionsRoute for the admin page's Tap groups picker * GET /keywords → the mappings as read, for a look while setting up
* GET /destinations → the destinations, for a quick look while setting up
* *
* ProPresenter is polled (GET /v1/status/slide, twice a second — one small * ProPresenter is polled (GET /v1/status/slide for the presentation layer,
* request, nothing heavier) because its chunked update stream is the thing * GET /v1/announcement/slide_index for announcements, once a second each —
* that goes quiet without saying so. Overflow is read on start, every few * small requests, nothing heavier) because its chunked update stream is the
* minutes after that, and on /refresh; every switch is confirmed by reading * thing that goes quiet without saying so. Overflow is the Dashboard's own
* the group back. Which Overflow call does the switching, and why that call * API, signed in with a Dashboard login — the calls, and where they come
* is the one unverified piece, is explained at the top of overflow.js. * 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 * 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. * tools/overflow-tap-mock.js, which also plays a ProPresenter on :1602.
*/ */
const { createOverflowClient, OverflowError, looksLikeId } = require('./overflow'); const crypto = require('crypto');
const { parseKeywordLines, matchKeyword, cleanNotes, norm } = require('./keywords'); const { createOverflowClient, OverflowError, describeSession } = require('./overflow');
const { matchKeywords, sameSet, cleanNotes, norm } = require('./keywords');
const POLL_MS = 500; // ProPresenter, while it answers const POLL_MS = 1000; // ProPresenter, while it answers
const RETRY_MS = 3000; // ProPresenter, while it does not const RETRY_MS = 3000; // ProPresenter, while it does not
const PP_TIMEOUT_MS = 4000; const PP_TIMEOUT_MS = 4000;
const HEARTBEAT_MS = 15000; const HEARTBEAT_MS = 15000;
const DESTINATIONS_REFRESH_MS = 5 * 60 * 1000; const KEYWORDS_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 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_RETRY_MS = 10 * 1000;
const SWITCH_RETRIES = 3; const SWITCH_RETRIES = 3;
const HISTORY_MAX = 8; // the tile shows four; a few more for the hover const HISTORY_MAX = 8; // the tile shows four; a few more for the hover
const ALERT_SECONDS = 30; 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. */ /** Set by init(), read by routes() — both are rebuilt together on a config save. */
let current = null; 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) { function json(res, code, body) {
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }); res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
res.end(JSON.stringify(body)); res.end(JSON.stringify(body));
@@ -60,20 +71,43 @@ function describe(err) {
return cause && !msg.includes(String(cause)) ? `${msg} (${cause})` : msg; 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 = { module.exports = {
init({ config, log, shell }) { init({ config, log, shell }) {
/* ── config ────────────────────────────────────────────────────── */ /* ── config ────────────────────────────────────────────────────── */
const ep = config.propresenter && typeof config.propresenter === 'object' ? config.propresenter : {}; const ep = config.propresenter && typeof config.propresenter === 'object' ? config.propresenter : {};
const host = String(ep.host || '').trim(); const host = String(ep.host || '').trim();
const port = Number(ep.port) || 1025; const port = Number(ep.port) || 1025;
const password = String(config.password || ''); const ppPassword = String(config.password || '');
const chosenGroups = (Array.isArray(config.groups) ? config.groups : []).map((g) => String(g || '').trim()).filter(Boolean); const email = String(config.overflowEmail || '').trim();
const mappings = parseKeywordLines(config.keywords); const ofPassword = String(config.overflowPassword || '');
const mode = MATCH_MODES.has(config.matching) ? config.matching : 'word'; const staffNonprofit = String(config.nonprofitId || '').trim();
const configured = Boolean(email && ofPassword);
const alertEnabled = Boolean(config.alertEnabled); const alertEnabled = Boolean(config.alertEnabled);
const alertChannel = Math.min(6, Math.max(1, Math.round(Number(config.alertChannel) || 1))); 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 ─────────────────────────────────────────────────────── */ /* ── state ─────────────────────────────────────────────────────── */
const streams = new Set(); const streams = new Set();
@@ -81,23 +115,35 @@ module.exports = {
let stopped = false; let stopped = false;
let lastFrame = ''; let lastFrame = '';
let destinations = []; // Overflow's, as listed let keywordCache = new Map(); // norm(keyword) → the Dashboard's entry
let groups = []; // Overflow's, as listed (each with its current destinationId) let session = null; // { token, expiresAt, email, nonprofitId }
let resolved = []; // the mappings, resolved against those two lists let lastLoginAt = 0;
let lastSlideKey = null; // change detection on the live slide let lastLoginError = null;
let wanted = null; // the resolved keyword Tap should be on let wanted = null; // the keywords Tap should be on: { keywords, keyword, destination, groups }
let inFlight = false; let inFlight = false;
let retriesLeft = 0; let retriesLeft = 0;
let retryTimer = null; let retryTimer = null;
let alertRaised = false; 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 = { const state = {
status: 'connecting', status: 'connecting',
message: '', message: '',
propresenter: { configured: Boolean(host), reachable: false, error: '', host, port }, 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: [], keywords: [],
slide: { live: false, keyword: '', unmapped: '', hasNotes: false }, slide: { live: false, keywords: [], hasNotes: false },
announcement: { live: false, keywords: [] },
current: null, current: null,
history: [], history: [],
updatedAt: 0, updatedAt: 0,
@@ -117,30 +163,26 @@ module.exports = {
/* ── health, shared by the admin line and the tiles ────────────── */ /* ── health, shared by the admin line and the tiles ────────────── */
function assess() { function assess(forAdmin = false) {
const pp = state.propresenter; const pp = state.propresenter;
const of = state.overflow; const of = state.overflow;
if (!pp.configured) return ['error', 'No ProPresenter host configured — set one in /admin']; 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 (!of.configured) return ['error', 'Overflow login not set — add the email and password in /admin'];
if (!mappings.length) return ['error', 'No keywords configured — add some in /admin']; if (!of.reachable) return ['error', of.error || 'Signing in to Overflow…'];
if (!of.reachable) return ['error', of.error || 'Reaching Overflow…'];
if (!pp.reachable) return ['error', pp.error ? `ProPresenter unreachable: ${pp.error}` : 'Reaching ProPresenter…']; if (!pp.reachable) return ['error', pp.error ? `ProPresenter unreachable: ${pp.error}` : 'Reaching ProPresenter…'];
const bad = state.keywords.filter((k) => k.problem); if (!state.keywords.length) return ['error', 'No keyword mappings in the Overflow Dashboard (Integrations › ProPresenter)'];
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) { if (state.current && !state.current.pending && !state.current.ok) {
return ['error', `Tap not switched to ${state.current.keyword} — ${state.current.error}`]; 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}…`]; 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 n = state.keywords.length;
const drift = state.current && state.current.drift ? ` · ${state.current.drift}` : ''; const who = forAdmin && session && session.email ? `${session.email}, ` : '';
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}`]; return ['ok', `Following ${host}:${port} · Overflow: ${who}${n} keyword${n === 1 ? '' : 's'}`];
} }
function publish() { function publish() {
if (stopped) return; if (stopped) return;
[state.status, state.message] = assess(); [state.status, state.message] = assess(false);
const { updatedAt, ...rest } = state; const { updatedAt, ...rest } = state;
let frame; let frame;
try { try {
@@ -161,103 +203,106 @@ module.exports = {
} }
} }
/* ── Overflow: destinations, groups, and the keywords resolved ─── */ /* ── the Overflow session ──────────────────────────────────────── */
function resolveKeywords() { async function ensureSession() {
const dById = new Map(destinations.map((d) => [String(d.id), d])); const now = Date.now();
const dByName = new Map(destinations.map((d) => [norm(d.name), d])); if (session && now < session.expiresAt - SESSION_MARGIN_MS) return session;
const gById = new Map(groups.map((g) => [String(g.id), g])); if (sessionCache && sessionCache.key === sessionKey && now < sessionCache.expiresAt - SESSION_MARGIN_MS) {
const gByName = new Map(groups.map((g) => [norm(g.name), g])); session = sessionCache;
const defaults = chosenGroups.length return session;
? 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('; ')}` : ''; 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; function dropSession() {
let groupsTimer = null; session = null;
let loading = null; if (sessionCache && sessionCache.key === sessionKey) sessionCache = null;
}
async function loadOverflow(what = 'all') { /** Run a signed-in call; when the session is refused, sign in again once and retry. */
if (stopped || !overflow.configured) return; 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; if (loading) return loading;
loading = (async () => { loading = (async () => {
try { try {
if (what === 'all' || what === 'destinations') destinations = await overflow.listDestinations(); const list = await withSession((s) => overflow.listKeywords(s.token, s.nonprofitId));
groups = await overflow.listGroups(); applyKeywordList(list);
state.overflow.reachable = true; state.overflow.reachable = true;
state.overflow.error = ''; state.overflow.error = '';
state.overflow.groups = groups.length;
state.overflow.destinations = destinations.length;
state.overflow.loadedAt = Date.now(); state.overflow.loadedAt = Date.now();
resolveKeywords(); reconsider(); // a slide already up when the mappings arrive takes effect now
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) { } catch (err) {
state.overflow.reachable = false; state.overflow.reachable = false;
state.overflow.error = describe(err); state.overflow.error = describe(err);
log(`Overflow: ${state.overflow.error}`); log(`Overflow: ${state.overflow.error}`);
if (!resolved.length) resolveKeywords(); // so the keyword list at least shows the lines
} }
publish(); publish();
})(); })();
@@ -266,24 +311,28 @@ module.exports = {
} finally { } finally {
loading = null; loading = null;
} }
scheduleOverflow();
}
function scheduleOverflow() {
if (stopped) return; if (stopped) return;
if (destinationsTimer) clearTimeout(destinationsTimer); if (refreshTimer) {
if (groupsTimer) clearTimeout(groupsTimer); clearTimeout(refreshTimer);
timers.delete(destinationsTimer); timers.delete(refreshTimer);
timers.delete(groupsTimer); }
const ok = state.overflow.reachable; refreshTimer = later(loadKeywords, state.overflow.reachable ? KEYWORDS_REFRESH_MS : OVERFLOW_RETRY_MS);
destinationsTimer = later(() => loadOverflow('all'), ok ? DESTINATIONS_REFRESH_MS : OVERFLOW_RETRY_MS);
groupsTimer = ok ? later(() => loadOverflow('groups'), GROUPS_REFRESH_MS) : null;
} }
/* ── the switch ────────────────────────────────────────────────── */ /* ── 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) { function pushHistory(entry) {
const { pending, drift, ...rest } = entry; const { pending, ...rest } = entry;
state.history.unshift(rest); state.history.unshift(rest);
if (state.history.length > HISTORY_MAX) state.history.length = HISTORY_MAX; if (state.history.length > HISTORY_MAX) state.history.length = HISTORY_MAX;
} }
@@ -316,7 +365,7 @@ module.exports = {
if (stopped || inFlight || !wanted) return; if (stopped || inFlight || !wanted) return;
const target = wanted; const target = wanted;
const cur = state.current; 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 if (sameTarget && cur.ok) return; // already there
inFlight = true; inFlight = true;
let entry; let entry;
@@ -326,15 +375,14 @@ module.exports = {
if (cur) pushHistory(cur); if (cur) pushHistory(cur);
entry = { entry = {
keyword: target.keyword, keyword: target.keyword,
destination: { id: target.destinationId, name: target.destination }, keywords: target.keywords,
groups: target.groups.map((g) => g.name), destination: target.destination,
groupIds: target.groups.map((g) => g.id), groups: target.groups,
at: Date.now(), at: Date.now(),
ok: false, ok: false,
pending: true, pending: true,
error: '', error: '',
attempts: 0, attempts: 0,
drift: '',
}; };
state.current = entry; state.current = entry;
} }
@@ -342,32 +390,23 @@ module.exports = {
entry.attempts += 1; entry.attempts += 1;
publish(); publish();
try { try {
for (const g of target.groups) { const answer = await withSession((s) => overflow.trigger(s.token, s.nonprofitId, target.keywords));
if (stopped) return; if (answer) {
await overflow.setGroupDestination(g.id, target.destinationId); applyKeywordList(answer, true);
const check = await overflow.getGroup(g.id); const fresh = describeTarget(target.keywords);
const nowAt = check ? String(check.destinationId || '') : ''; entry.destination = fresh.destination || entry.destination;
if (check && nowAt !== target.destinationId) { entry.groups = fresh.groups.length ? fresh.groups : entry.groups;
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.ok = true;
entry.error = ''; entry.error = '';
entry.at = Date.now(); entry.at = Date.now();
entry.drift = '';
clearAlert(); 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) { } catch (err) {
if (stopped) return; if (stopped) return;
entry.ok = false; entry.ok = false;
entry.error = describe(err); 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) { if (retriesLeft > 0) {
retriesLeft -= 1; retriesLeft -= 1;
retryTimer = later(() => { 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) { function ppUrl(target) {
const u = new URL(`http://${host}:${port}${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; return u;
} }
@@ -397,7 +463,11 @@ module.exports = {
const timer = setTimeout(() => ctl.abort(), PP_TIMEOUT_MS); const timer = setTimeout(() => ctl.abort(), PP_TIMEOUT_MS);
try { try {
const res = await fetch(ppUrl(target), { headers: { Accept: 'application/json' }, signal: ctl.signal }); 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(); const text = await res.text();
return text ? JSON.parse(text) : null; return text ? JSON.parse(text) : null;
} finally { } finally {
@@ -405,62 +475,96 @@ module.exports = {
} }
} }
let lastSlide = null; /** The presentation layer: /v1/status/slide names the live slide and carries its notes. */
async function pollSlide() {
function reconsiderSlide() { const data = await ppJson('/v1/status/slide');
const cur = lastSlide; const cur = data && data.current && typeof data.current === 'object' ? data.current : null;
if (!cur) { const key = cur ? `${cur.uuid || ''}\u0000${cur.text || ''}\u0000${cur.notes || ''}` : '';
state.slide = { live: false, keyword: '', unmapped: '', hasNotes: false }; if (key === lastSlideKey) return false;
return; lastSlideKey = key;
} lastSlide = cur;
const notes = cleanNotes(cur.notes); return true;
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() { /**
* 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; if (stopped) return;
let ok = false; let ok = false;
let changed = false;
try { try {
const data = await ppJson('/v1/status/slide'); changed = await pollSlide();
const cur = data && data.current && typeof data.current === 'object' ? data.current : null;
state.propresenter.reachable = true; state.propresenter.reachable = true;
state.propresenter.error = ''; state.propresenter.error = '';
ok = true; ok = true;
const key = cur ? `${cur.uuid || ''}\u0000${cur.text || ''}\u0000${cur.notes || ''}` : ''; try {
if (key !== lastSlideKey) { if (await pollAnnouncement()) changed = true;
lastSlideKey = key; } catch {
lastSlide = cur; /* the presentation poll already said ProPresenter answers; a hiccup here waits for the next tick */
reconsiderSlide();
} }
} catch (err) { } catch (err) {
state.propresenter.reachable = false; state.propresenter.reachable = false;
state.propresenter.error = err && err.name === 'AbortError' ? 'timed out' : describe(err); state.propresenter.error = err && err.name === 'AbortError' ? 'timed out' : describe(err);
} }
if (changed) reconsider();
publish(); publish();
later(pollSlide, ok ? POLL_MS : RETRY_MS); later(pollTick, ok ? POLL_MS : RETRY_MS);
} }
/* ── go ────────────────────────────────────────────────────────── */ /* ── go ────────────────────────────────────────────────────────── */
resolveKeywords();
if (host) { if (host) {
pollSlide(); pollTick();
log(`following ProPresenter at ${host}:${port}`); log(`following ProPresenter at ${host}:${port}`);
} else { } else {
log('no ProPresenter host configured — set one in /admin'); log('no ProPresenter host configured — set one in /admin');
} }
if (overflow.configured) { if (configured) {
loadOverflow('all'); loadKeywords();
log(`Overflow at ${overflow.base}`); log(`Overflow at ${overflow.base}`);
} else { } 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(); publish();
@@ -477,11 +581,9 @@ module.exports = {
const handle = { const handle = {
streams, streams,
configured,
getState: () => state, getState: () => state,
refresh: () => loadOverflow('all'), refresh: () => loadKeywords(),
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; current = handle;
@@ -499,7 +601,7 @@ module.exports = {
if (current === handle) current = null; if (current === handle) current = null;
}, },
health() { health() {
const [status, message] = assess(); const [status, message] = assess(true);
return { status, message }; return { status, message };
}, },
}; };
@@ -529,23 +631,17 @@ module.exports = {
'POST /refresh': async (req, res) => { 'POST /refresh': async (req, res) => {
if (!current) return json(res, 503, { error: 'Overflow Tap is not running.' }); 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(); await current.refresh();
const st = current.getState(); const st = current.getState();
if (!st.overflow.reachable) return json(res, 502, { error: st.overflow.error || 'Overflow unreachable.' }); 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) => { 'GET /keywords': (req, res) => {
if (!current) return json(res, 200, { options: [] }); if (!current) return json(res, 503, { error: 'Overflow Tap is not running.' });
if (current.configured && !current.groupOptions().length) await current.refresh(); const st = current.getState();
json(res, 200, { options: current.groupOptions() }); json(res, 200, { keywords: st.keywords, loadedAt: st.overflow.loadedAt, error: st.overflow.error });
},
'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() });
}, },
}; };
}, },
+182 -110
View File
@@ -1,46 +1,55 @@
/** /**
* Mock ProPresenter slide status + mock Overflow Tap API, for developing the * Mock ProPresenter slide status + mock Overflow Dashboard API, for developing
* overflow-tap module without a live ProPresenter or an Overflow account. * the overflow-tap module without a live ProPresenter or an Overflow account.
* *
* node tools/overflow-tap-mock.js [--pp=1602] [--overflow=24720] [--step=4000] * node tools/overflow-tap-mock.js [--pp=1602] [--overflow=24720] [--step=4000]
* [--key=<apiKey>] [--read-only] [--slow] * [--login=<email>:<password>] [--expire=<seconds>]
* [--fail-switch] [--slow] [--no-announcements]
* *
* ProPresenter side (127.0.0.1:1602 by default) — the one route the module * ProPresenter side (127.0.0.1:1602 by default) — the routes the module uses,
* uses, with a service that advances every --step ms: * with a service that advances every --step ms:
* GET /version reachability probe * GET /version reachability probe
* GET /v1/status/slide { current: { uuid, text, notes }, next }, cycling * GET /v1/status/slide { current: { uuid, text, notes }, next }, cycling
* through slides whose notes carry keywords * through slides whose notes carry keywords
* ("give", "Next steps: connect", "prayer"), slides * ("give", "Next steps: connect", "prayer"),
* with other notes ("forgiveness" — must NOT fire * slides with other notes ("forgiveness" —
* "give" in whole-word mode), a slide whose keyword * must NOT fire "give"), a slide whose
* maps to nothing on the account, and a stretch with * keyword is not mapped on the account, and
* nothing live. * a stretch with nothing live.
* GET /v1/announcement/slide_index the announcement layer: a three-slide
* document whose index advances every third
* step; its middle slide carries "events".
* GET /v1/announcement/active that document, groups → slides → notes.
* --no-announcements answers 404 to both,
* like an older ProPresenter.
* *
* Overflow side (127.0.0.1:24720 by default; run ProdDash with * Overflow side (127.0.0.1:24720 by default; run ProdDash with
* OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720) — the documented reads with * OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720) — the Dashboard API the
* paging, and the switch the module sends: * module speaks (see modules/overflow-tap/overflow.js):
* GET /api/v3/tap/destinations four destinations * POST /nonprofit-dashboard/authentication/login/jwt any non-empty email +
* GET /api/v3/tap/groups three groups, each pointing somewhere * password signs in (--login=<email>:<password> demands that pair,
* GET /api/v3/tap/groups/{id} * answering 401 otherwise); the token expires after --expire seconds
* PATCH /api/v3/tap/groups/{id} { destinationId } — moves the group. * (3600 by default — set 20 to exercise the sign-in-again path). An
* --read-only answers 404 instead, which * @overflow.co email gets a staff token with no nonprofit of its own.
* is what Overflow's public API does * GET /nonprofit-dashboard/propresenter/nonprofits staff: the nonprofits
* today, to exercise the failure path. * POST /nonprofit-dashboard/propresenter/nonprofits/{id}/select
* Every request must carry x-client-id and x-api-key (any non-empty values; * GET /nonprofit-dashboard/propresenter/nonprofits/{id}/keywords four mappings:
* --key=<apiKey> demands that exact key, answering 401 otherwise). --slow adds * give → Give Online (every group), connect → Connect Card (Lobby),
* a two-second delay to every Overflow answer. * prayer → Prayer Request (every group), events → Events (Lobby, Kids
* * Wing). "baptism" is deliberately unmapped.
* Suggested admin Keywords for this mock: * POST /nonprofit-dashboard/propresenter/nonprofits/{id}/keywords?syncNonprofit=true
* give = Give Online * { keywords } — moves the groups and answers the updated list.
* connect = Connect Card @ Lobby * --fail-switch answers 500 instead, to exercise the failure path.
* prayer = Prayer Request * Every signed-in call needs a Bearer token this mock issued and has not
* baptism = Baptism Signup * expired, or it answers 401. --slow adds a two-second delay to every Overflow
* answer.
* *
* Every incoming request is logged so we can confirm what the module does. * Every incoming request is logged so we can confirm what the module does.
*/ */
'use strict'; 'use strict';
const http = require('http'); const http = require('http');
const crypto = require('crypto');
const args = process.argv.slice(2); const args = process.argv.slice(2);
const opt = (name, dflt) => { const opt = (name, dflt) => {
@@ -50,9 +59,16 @@ const opt = (name, dflt) => {
const PP_PORT = Number.parseInt(opt('pp', '1602'), 10); const PP_PORT = Number.parseInt(opt('pp', '1602'), 10);
const OF_PORT = Number.parseInt(opt('overflow', '24720'), 10); const OF_PORT = Number.parseInt(opt('overflow', '24720'), 10);
const STEP_MS = Number.parseInt(opt('step', '4000'), 10); const STEP_MS = Number.parseInt(opt('step', '4000'), 10);
const KEY = opt('key', ''); const LOGIN = opt('login', '');
const READ_ONLY = args.includes('--read-only'); const EXPIRE_S = Number.parseInt(opt('expire', '3600'), 10);
const FAIL_SWITCH = args.includes('--fail-switch');
const SLOW = args.includes('--slow'); const SLOW = args.includes('--slow');
const NO_ANN = args.includes('--no-announcements');
function sendJson(res, code, body, extra = {}) {
res.writeHead(code, { 'Content-Type': 'application/json', ...extra });
res.end(body === undefined ? '' : JSON.stringify(body));
}
/* ── ProPresenter ────────────────────────────────────────────────── */ /* ── ProPresenter ────────────────────────────────────────────────── */
@@ -68,116 +84,172 @@ const SLIDES = [
{ uuid: 'S8', text: 'Give online (again)', notes: 'give' }, { uuid: 'S8', text: 'Give online (again)', notes: 'give' },
{ uuid: 'S9', text: 'See you next week', notes: '' }, { uuid: 'S9', text: 'See you next week', notes: '' },
]; ];
let slideIdx = 0; const ANNOUNCEMENT = {
setInterval(() => { slideIdx = (slideIdx + 1) % SLIDES.length; }, STEP_MS).unref(); id: { uuid: 'ANN-1', name: 'Announcements', index: 0 },
presentation_path: 'Announcements',
groups: [
{ name: 'Loop', slides: [
{ enabled: true, notes: '', text: 'Welcome', label: '' },
{ enabled: true, notes: 'events', text: 'Upcoming events', label: '' },
{ enabled: true, notes: '', text: 'Thanks for coming', label: '' },
] },
],
};
let step = 0;
setInterval(() => { step += 1; }, STEP_MS).unref();
const slideIdx = () => step % SLIDES.length;
const annIdx = () => Math.floor(step / 3) % ANNOUNCEMENT.groups[0].slides.length;
function slidePayload() { function slidePayload() {
const cur = SLIDES[slideIdx]; const cur = SLIDES[slideIdx()];
const nxt = SLIDES[(slideIdx + 1) % SLIDES.length]; const nxt = SLIDES[(slideIdx() + 1) % SLIDES.length];
return { return {
current: cur ? { uuid: cur.uuid, text: cur.text, notes: cur.notes } : null, current: cur ? { uuid: cur.uuid, text: cur.text, notes: cur.notes } : null,
next: nxt ? { uuid: nxt.uuid, text: nxt.text, notes: nxt.notes } : null, next: nxt ? { uuid: nxt.uuid, text: nxt.text, notes: nxt.notes } : null,
}; };
} }
function sendJson(res, code, body, extra = {}) {
res.writeHead(code, { 'Content-Type': 'application/json', ...extra });
res.end(JSON.stringify(body));
}
http.createServer((req, res) => { http.createServer((req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`); const url = new URL(req.url, `http://${req.headers.host}`);
const p = url.pathname; const p = url.pathname;
console.log(`[pp] ${req.method} ${p}`); console.log(`[pp] ${req.method} ${p}`);
if (p === '/version') return sendJson(res, 200, { name: 'Overflow mock', platform: 'mac', host_description: 'ProPresenter 21 (mock)', api_version: 'v1' }); if (p === '/version') return sendJson(res, 200, { name: 'Overflow mock', platform: 'mac', host_description: 'ProPresenter 21 (mock)', api_version: 'v1' });
if (p === '/v1/status/slide') return sendJson(res, 200, slidePayload()); if (p === '/v1/status/slide') return sendJson(res, 200, slidePayload());
if (p === '/v1/status/layers') return sendJson(res, 200, { slide: Boolean(SLIDES[slideIdx]), media: false, announcements: false }); if (p === '/v1/status/layers') return sendJson(res, 200, { slide: Boolean(SLIDES[slideIdx()]), media: false, announcements: !NO_ANN });
if (p === '/v1/announcement/slide_index') {
if (NO_ANN) return sendJson(res, 404, { error: 'not found' });
return sendJson(res, 200, { announcement_index: { index: annIdx(), presentation_id: ANNOUNCEMENT.id } });
}
if (p === '/v1/announcement/active') {
if (NO_ANN) return sendJson(res, 404, { error: 'not found' });
return sendJson(res, 200, { announcement: ANNOUNCEMENT });
}
sendJson(res, 404, { error: 'not found' }); sendJson(res, 404, { error: 'not found' });
}).listen(PP_PORT, '127.0.0.1', () => { }).listen(PP_PORT, '127.0.0.1', () => {
console.log(`[pp] mock ProPresenter on http://127.0.0.1:${PP_PORT} (slide every ${STEP_MS} ms)`); console.log(`[pp] mock ProPresenter on http://127.0.0.1:${PP_PORT} (slide every ${STEP_MS} ms${NO_ANN ? ', no announcement layer' : ''})`);
}); });
/* ── Overflow ────────────────────────────────────────────────────── */ /* ── Overflow Dashboard ──────────────────────────────────────────── */
const stamp = (d) => new Date(d).toISOString();
const DESTINATIONS = [
{ id: '6710f34fd5061afeec3eab01', name: 'Give Online', type: 'web', url: 'https://example.org/give', textNumber: null, textContent: null },
{ id: '6710f34fd5061afeec3eab02', name: 'Connect Card', type: 'web', url: 'https://example.org/connect', textNumber: null, textContent: null },
{ id: '6710f34fd5061afeec3eab03', name: 'Prayer Request', type: 'web', url: 'https://example.org/pray', textNumber: null, textContent: null },
{ id: '6710f34fd5061afeec3eab04', name: 'Events', type: 'sms', url: null, textNumber: '+15550100', textContent: 'EVENTS' },
].map((d) => ({ ...d, createdAt: stamp('2026-05-01T17:22:11Z'), updatedAt: stamp('2026-05-04T14:09:32Z'), archivedAt: null }));
const NONPROFIT = { id: '6710f34fd5061afeec3eab00', name: 'Waters Church (mock)', slug: 'waters-church' };
const DESTINATIONS = {
give: { id: '6710f34fd5061afeec3eab01', name: 'Give Online' },
connect: { id: '6710f34fd5061afeec3eab02', name: 'Connect Card' },
prayer: { id: '6710f34fd5061afeec3eab03', name: 'Prayer Request' },
events: { id: '6710f34fd5061afeec3eab04', name: 'Events' },
};
const GROUPS = [ const GROUPS = [
{ id: '6710f34fd5061afeec3eab11', name: 'Auditorium', destinationId: DESTINATIONS[3].id, deviceIds: ['6710f34fd5061afeec3eab21', '6710f34fd5061afeec3eab22'] }, { id: '6710f34fd5061afeec3eab11', tapGroupName: 'Auditorium', destinationId: DESTINATIONS.events.id },
{ id: '6710f34fd5061afeec3eab12', name: 'Lobby', destinationId: DESTINATIONS[3].id, deviceIds: ['6710f34fd5061afeec3eab23'] }, { id: '6710f34fd5061afeec3eab12', tapGroupName: 'Lobby', destinationId: DESTINATIONS.events.id },
{ id: '6710f34fd5061afeec3eab13', name: 'Kids Wing', destinationId: DESTINATIONS[1].id, deviceIds: [] }, { id: '6710f34fd5061afeec3eab13', tapGroupName: 'Kids Wing', destinationId: DESTINATIONS.connect.id },
].map((g) => ({ ...g, createdAt: stamp('2026-05-01T17:22:11Z'), updatedAt: stamp('2026-05-04T14:09:32Z'), archivedAt: null })); ];
const groupRef = (g) => ({ id: g.id, tapGroupName: g.tapGroupName });
const KEYWORDS = [
{ id: 'kw1', keyword: 'give', destinationId: DESTINATIONS.give.id, destinationName: DESTINATIONS.give.name, tapGroups: GROUPS.map(groupRef), active: true },
{ id: 'kw2', keyword: 'connect', destinationId: DESTINATIONS.connect.id, destinationName: DESTINATIONS.connect.name, tapGroups: [groupRef(GROUPS[1])], active: true },
{ id: 'kw3', keyword: 'prayer', destinationId: DESTINATIONS.prayer.id, destinationName: DESTINATIONS.prayer.name, tapGroups: GROUPS.map(groupRef), active: true },
{ id: 'kw4', keyword: 'events', destinationId: DESTINATIONS.events.id, destinationName: DESTINATIONS.events.name, tapGroups: [groupRef(GROUPS[1]), groupRef(GROUPS[2])], active: true },
];
const stampedKeywords = () => KEYWORDS.map((k) => ({ ...k, nonprofitId: NONPROFIT.id, updatedAt: new Date().toISOString() }));
function destView(id) { const tokens = new Map(); // token → { email, exp, nonprofitId }
const d = DESTINATIONS.find((x) => x.id === id);
return d ? { id: d.id, name: d.name, type: d.type, url: d.url, textNumber: d.textNumber, textContent: d.textContent } : null; function issueToken(email) {
} const staff = email.toLowerCase().endsWith('@overflow.co');
function groupView(g) { const now = Math.floor(Date.now() / 1000);
return { ...g, destination: destView(g.destinationId) }; const payload = {
} id: 'user-1',
function page(list, url) { email,
const limit = Math.min(100, Math.max(1, Number(url.searchParams.get('limit')) || 25)); fullName: 'Mock User',
const pg = Math.max(1, Number(url.searchParams.get('page')) || 1); role: staff ? 'overflowSupport' : 'orgAdmin',
const start = (pg - 1) * limit; permissions: [{ action: 'manage', subject: 'TAP' }],
return { data: list.slice(start, start + limit), totalCount: list.length }; nonprofits: staff ? [] : [{ id: NONPROFIT.id, name: NONPROFIT.name, slug: NONPROFIT.slug }],
iat: now,
exp: now + EXPIRE_S,
};
if (!staff) payload.nonprofitId = NONPROFIT.id;
const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
const token = `${b64({ alg: 'HS256', typ: 'JWT' })}.${b64(payload)}.${crypto.randomBytes(16).toString('base64url')}`;
tokens.set(token, { email, exp: payload.exp, nonprofitId: staff ? '' : NONPROFIT.id });
return token;
} }
let served = 0; function bearer(req) {
http.createServer((req, res) => { const m = /^Bearer\s+(.+)$/.exec(String(req.headers.authorization || ''));
if (!m) return null;
const t = tokens.get(m[1]);
if (!t) return null;
if (Date.now() / 1000 >= t.exp) return null;
return t;
}
function readBody(req) {
return new Promise((resolve) => {
let body = '';
req.on('data', (c) => (body += c));
req.on('end', () => {
try { resolve(JSON.parse(body || '{}')); } catch { resolve(null); }
});
});
}
http.createServer(async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`); const url = new URL(req.url, `http://${req.headers.host}`);
const p = url.pathname; const p = url.pathname;
served += 1;
const rl = { 'x-ratelimit-limit': '120', 'x-ratelimit-remaining': String(Math.max(0, 120 - (served % 120))), 'x-ratelimit-reset': '59' };
const answer = (code, body) => { const answer = (code, body) => {
const go = () => sendJson(res, code, body, rl); const go = () => sendJson(res, code, body);
if (SLOW) setTimeout(go, 2000); if (SLOW) setTimeout(go, 2000);
else go(); else go();
}; };
const cid = String(req.headers['x-client-id'] || ''); console.log(`[overflow] ${req.method} ${p}${url.search}`);
const key = String(req.headers['x-api-key'] || '');
console.log(`[overflow] ${req.method} ${p}${url.search}${cid ? '' : ' (no client id)'}${key ? '' : ' (no api key)'}`);
if (!cid || !key || (KEY && key !== KEY)) return answer(401, { statusCode: 401, message: 'Unauthorized' });
if (req.method === 'GET' && p === '/api/v3/tap/destinations') return answer(200, page(DESTINATIONS, url)); if (req.method === 'POST' && p === '/nonprofit-dashboard/authentication/login/jwt') {
const dOne = /^\/api\/v3\/tap\/destinations\/([^/]+)$/.exec(p); const body = await readBody(req);
if (req.method === 'GET' && dOne) { const email = String((body && body.email) || '').trim();
const d = DESTINATIONS.find((x) => x.id === dOne[1]); const password = String((body && body.password) || '');
return d ? answer(200, { data: d }) : answer(404, { statusCode: 404, message: 'Destination not found' }); if (!email || !password) return answer(400, { statusCode: 400, message: 'email and password are required' });
if (LOGIN && `${email}:${password}` !== LOGIN) return answer(401, { statusCode: 401, message: 'Invalid email or password' });
console.log(`[overflow] signed in ${email} (token expires in ${EXPIRE_S} s)`);
return answer(200, { token: issueToken(email) });
} }
if (req.method === 'GET' && p === '/api/v3/tap/groups') return answer(200, page(GROUPS.map(groupView), url));
const gOne = /^\/api\/v3\/tap\/groups\/([^/]+)$/.exec(p); const session = bearer(req);
if (gOne) { if (!session) return answer(401, { statusCode: 401, message: 'Unauthorized' });
const g = GROUPS.find((x) => x.id === gOne[1]);
if (!g) return answer(404, { statusCode: 404, message: 'Group not found' }); if (req.method === 'GET' && p === '/nonprofit-dashboard/propresenter/nonprofits') {
if (req.method === 'GET') return answer(200, { data: groupView(g) }); return answer(200, { data: { nonprofits: [NONPROFIT] } });
if (req.method === 'PATCH' || req.method === 'PUT') { }
if (READ_ONLY) return answer(404, { statusCode: 404, message: `Cannot ${req.method} /api/v3/tap/groups/${g.id}` }); const sel = /^\/nonprofit-dashboard\/propresenter\/nonprofits\/([^/]+)\/select$/.exec(p);
let body = ''; if (req.method === 'POST' && sel) {
req.on('data', (c) => (body += c)); if (sel[1] !== NONPROFIT.id) return answer(404, { statusCode: 404, message: 'Nonprofit not found' });
req.on('end', () => { session.nonprofitId = NONPROFIT.id;
let parsed = {}; return answer(204);
try { parsed = JSON.parse(body || '{}'); } catch { return answer(400, { statusCode: 400, message: 'Invalid JSON' }); } }
const dest = DESTINATIONS.find((x) => x.id === parsed.destinationId); const kw = /^\/nonprofit-dashboard\/propresenter\/nonprofits\/([^/]+)\/keywords$/.exec(p);
if (!dest) return answer(400, { statusCode: 400, message: 'destinationId must be an existing destination' }); if (kw) {
g.destinationId = dest.id; if (kw[1] !== NONPROFIT.id || session.nonprofitId !== NONPROFIT.id) return answer(403, { statusCode: 403, message: 'Forbidden' });
g.updatedAt = stamp(Date.now()); if (req.method === 'GET') return answer(200, stampedKeywords());
console.log(`[overflow] ${g.name} → ${dest.name}`); if (req.method === 'POST') {
answer(200, { data: groupView(g) }); const body = await readBody(req);
}); const keys = body && Array.isArray(body.keywords) ? body.keywords.map((k) => String(k).toLowerCase().trim()) : [];
return; if (!keys.length) return answer(400, { statusCode: 400, message: 'keywords is required' });
if (FAIL_SWITCH) return answer(500, { statusCode: 500, message: 'Internal server error' });
for (const k of keys) {
const entry = KEYWORDS.find((x) => x.keyword === k);
if (!entry) {
console.log(`[overflow] "${k}" is not a mapped keyword — ignored`);
continue;
}
for (const g of entry.tapGroups) {
const group = GROUPS.find((x) => x.id === g.id);
if (group) group.destinationId = entry.destinationId;
}
console.log(`[overflow] ${entry.tapGroups.map((g) => g.tapGroupName).join(', ')} → ${entry.destinationName} (${k})`);
}
return answer(200, stampedKeywords());
} }
} }
if (req.method === 'GET' && p === '/api/v3/tap/devices') {
const devices = GROUPS.flatMap((g) => g.deviceIds.map((id) => ({ id, serialNumber: `TAP-${id.slice(-4)}`, groupId: g.id })));
return answer(200, page(devices, url));
}
if (req.method === 'GET' && p === '/api/v3/tap/events') return answer(200, { data: [], totalCount: 0 });
answer(404, { statusCode: 404, message: `Cannot ${req.method} ${p}` }); answer(404, { statusCode: 404, message: `Cannot ${req.method} ${p}` });
}).listen(OF_PORT, '127.0.0.1', () => { }).listen(OF_PORT, '127.0.0.1', () => {
console.log(`[overflow] mock Overflow API on http://127.0.0.1:${OF_PORT}${READ_ONLY ? ' (read-only: PATCH answers 404)' : ''}${KEY ? ' (api key required)' : ''}`); console.log(`[overflow] mock Overflow Dashboard API on http://127.0.0.1:${OF_PORT}${FAIL_SWITCH ? ' (every switch answers 500)' : ''}${LOGIN ? ' (one login accepted)' : ''}${EXPIRE_S < 3600 ? ` (tokens expire after ${EXPIRE_S} s)` : ''}`);
}); });