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/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/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)
+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
intervals and a position trim.
Tile settings: cue name, previous cue, playhead, cue list.
- **Overflow Tap** — what Overflow's own ProPresenter helper app does,
inside ProdDash: the live slide's notes are read from ProPresenter, and
when they carry one of the admin page's keywords, the chosen Overflow Tap
groups are pointed at that keyword's destination — so a tap on a disc
follows what is on screen. A slide with no keyword changes nothing. The
tile is one keyword, big: what Tap is on (and since when), its destination
and the groups moved, a chip saying whether the slide on screen carries a
keyword, and the last four switches with their times; a failed switch
turns the tile red with the reason and, if switched on in admin, flashes
an alert channel. Admin config: the ProPresenter endpoint, the Overflow
API credentials (Developers › API Keys in the Overflow Dashboard), the Tap
groups to switch (read from the account), the keywords — one per line as
`give = Give Online`, optionally `@ Lobby, Kids Wing` to switch only
those groups — and how a keyword matches (whole word, anywhere, or on its
own line). Tile settings are four switches: destination, groups, slide
match, history. **One thing is still unverified:** Overflow's public API
documents only *reads* for Tap, so the call that moves a group (`PATCH
/api/v3/tap/groups/{id}` with the new `destinationId`) is the natural
shape, not a documented one; every switch is confirmed by reading the
group back, so a refusal shows on the tile rather than passing silently.
- **Overflow Tap** — what Overflow's own "ProPresenter Integration" helper
app does, inside ProdDash, speaking the same Dashboard API it does: the
live slide's notes are read from ProPresenter (the presentation layer and
the announcement layer), and when they carry one of the keywords mapped in
the Overflow Dashboard, Overflow is told — and points that keyword's Tap
groups at its destination, so a tap on a disc follows what is on screen.
A slide with no keyword changes nothing. Keywords match as the helper
matches them: as a whole word of the notes, any case, punctuation around
it ignored (`give` fires on "Give!" and not on "forgiveness"). The tile is
one keyword, big: what Tap is on (and since when), its destination and the
groups moved, a chip saying whether the slide on screen carries a keyword,
and the last four switches with their times; a failed switch turns the
tile red with the reason, is retried, and — if switched on in admin —
flashes an alert channel. Admin config: the ProPresenter endpoint and an
**Overflow Dashboard login** (email and password; Google sign-in has no
password, so set one with "Forgot Your Password?"). The keywords, their
destinations and their Tap groups stay where Overflow keeps them —
Integrations › ProPresenter › Keyword Mappings in the Dashboard — and are
read from there on start, every few minutes, and on the tile's refresh
button. Tile settings are four switches: destination, groups, slide
match, history.
- **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
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
`BAND_LINEUP_API_BASE=http://127.0.0.1:24710`) and
`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`
— run ProdDash with `OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720`;
`--read-only` makes the switch fail the way the public API does today). See
the testing section of the [module guide](docs/MODULE-GUIDE.md).
keywords on `127.0.0.1:1602`, announcement layer included, plus the Overflow
Dashboard API on `127.0.0.1:24720` — run ProdDash with
`OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720` and sign the module in with
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
+23 -21
View File
@@ -45,8 +45,8 @@ export default function create({ root, moduleApi }) {
const pp = state.propresenter || {};
const of = state.overflow || {};
if (!pp.configured) return 'No ProPresenter host — set one in /admin';
if (!of.configured) return 'No Overflow API key — add it in /admin';
if (!(state.keywords || []).length) return 'No keywords — add them in /admin';
if (!of.configured) return 'No Overflow login — add it in /admin';
if (of.reachable && !(state.keywords || []).length) return 'No keyword mappings in the Overflow Dashboard yet';
return '';
}
@@ -78,12 +78,11 @@ export default function create({ root, moduleApi }) {
if (cur) {
// The label carries the state and, when there is one, the moment it
// became true; the small lines under the destination are the groups
// moved — or, when the switch failed or was undone in Overflow, what
// went wrong, in their place (a tile has room for one or the other).
// moved — or, when the switch failed, what went wrong, in their place
// (a tile has room for one or the other).
let label = `TAP IS ON · since ${clock(cur.at)}`;
let tone = 'ok';
let note = '';
let noteClass = 'ot-error';
if (cur.pending) {
label = cur.attempts > 1 ? `SWITCHING · attempt ${cur.attempts}` : 'SWITCHING';
tone = 'muted';
@@ -91,17 +90,12 @@ export default function create({ root, moduleApi }) {
label = `NOT SWITCHED · ${clock(cur.at)}`;
tone = 'alert';
note = cur.error || '';
} else if (cur.drift) {
label = `TAP WAS ON · since ${clock(cur.at)}`;
tone = 'warn';
note = cur.drift;
noteClass = 'ot-error is-warn';
}
main.dataset.tone = tone;
main.append(el('div', 'ot-label', label));
main.append(el('div', 'ot-keyword', cur.keyword));
if (on('showDestination')) main.append(el('div', 'ot-dest', cur.destination ? cur.destination.name : ''));
if (note) main.append(el('div', noteClass, note));
if (on('showDestination')) main.append(el('div', 'ot-dest', cur.destination || ''));
if (note) main.append(el('div', 'ot-error', note));
else if (on('showGroups') && cur.groups && cur.groups.length) main.append(el('div', 'ot-groups', cur.groups.join(' · ')));
} else {
main.dataset.tone = 'muted';
@@ -113,13 +107,21 @@ export default function create({ root, moduleApi }) {
if (on('showSlide')) {
const slide = state.slide || {};
const ann = state.announcement || {};
const slideKeys = Array.isArray(slide.keywords) ? slide.keywords : [];
const annKeys = Array.isArray(ann.keywords) ? ann.keywords : [];
let text;
let cls = 'ot-slide';
if (!pp.reachable) { text = 'ProPresenter: not answering'; cls += ' is-muted'; }
else if (!slide.live) { text = 'Nothing live in ProPresenter'; cls += ' is-muted'; }
else if (slide.unmapped) { text = `This slide: ${slide.keyword} — ${slide.unmapped}`; cls += ' is-warn'; }
else if (slide.keyword) { text = `This slide: ${slide.keyword}`; cls += ' is-hit'; }
else { text = slide.hasNotes ? 'This slide: no keyword' : 'This slide: no notes'; cls += ' is-muted'; }
else if (!slide.live && !ann.live) { text = 'Nothing live in ProPresenter'; cls += ' is-muted'; }
else if (slideKeys.length || annKeys.length) {
const parts = [];
if (slideKeys.length) parts.push(`This slide: ${slideKeys.join(' + ')}`);
if (annKeys.length) parts.push(`Announcement: ${annKeys.join(' + ')}`);
text = parts.join(' · ');
cls += ' is-hit';
} else if (slide.live) { text = slide.hasNotes ? 'This slide: no keyword' : 'This slide: no notes'; cls += ' is-muted'; }
else { text = 'Announcement: no keyword'; cls += ' is-muted'; }
box.append(el('div', cls, text));
}
@@ -132,11 +134,11 @@ export default function create({ root, moduleApi }) {
for (const h of rows) {
const row = el('li', `ot-h-row${h.ok ? '' : ' is-failed'}`);
row.title = h.ok
? `${h.keyword} → ${h.destination ? h.destination.name : ''}${h.groups && h.groups.length ? ` on ${h.groups.join(', ')}` : ''}`
? `${h.keyword} → ${h.destination || ''}${h.groups && h.groups.length ? ` on ${h.groups.join(', ')}` : ''}`
: `${h.keyword}: ${h.error || 'failed'}`;
row.append(el('span', 'ot-h-time', clock(h.at)));
row.append(el('span', 'ot-h-kw', h.keyword));
row.append(el('span', 'ot-h-dest', h.destination ? h.destination.name : ''));
row.append(el('span', 'ot-h-dest', h.destination || ''));
row.append(el('span', 'ot-h-mark', h.ok ? '✓' : '✕'));
list.append(row);
}
@@ -182,12 +184,12 @@ export default function create({ root, moduleApi }) {
try {
const res = await moduleApi.fetch('/refresh', { method: 'POST' });
if (!res.ok) {
let why = `Overflow refresh failed (${res.status})`;
let why = `Keyword refresh failed (${res.status})`;
try { why = (await res.json()).error || why; } catch { /* keep the status */ }
moduleApi.setStatus('error', why);
}
} catch (err) {
moduleApi.setStatus('error', `Overflow refresh failed: ${err && err.message ? err.message : err}`);
moduleApi.setStatus('error', `Keyword refresh failed: ${err && err.message ? err.message : err}`);
} finally {
refreshBtn.classList.remove('active');
}
@@ -198,7 +200,7 @@ export default function create({ root, moduleApi }) {
moduleApi.setStatus('connecting', 'Connecting');
refreshBtn = moduleApi.header.addButton({
icon: ICON_REFRESH,
title: 'Reload Overflow',
title: 'Refresh keywords',
onClick: refresh,
});
stream = moduleApi.sse('/stream', {
+31 -66
View File
@@ -1,27 +1,21 @@
'use strict';
/**
* Keyword mappings — reading the admin page's Keywords text and deciding
* which keyword a slide's notes carry.
* Keyword matching — which of Overflow's keywords a slide's notes carry.
*
* Pure functions, no I/O, so keywords.selftest.js can pin every rule down:
*
* parseKeywordLines(text) → [{ line, keyword, destination, groups, problem }]
* keywordMatches(notes, keyword, mode) → boolean
* matchKeyword(notes, mappings, mode) → the first mapping whose keyword the notes carry, or null
* cleanNotes(raw) → the notes with ProPresenter's invisible padding removed
* keywordMatches(notes, keyword) → boolean
* matchKeywords(notes, keywords) → every keyword (in the order given) the notes carry
* sameSet(a, b) → the two keyword lists hold the same keywords
*
* The Keywords text is one mapping per line:
*
* give = Give Online
* connect -> Connect Card @ Lobby, Kids Wing
* # a comment
*
* `=`, `->` 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.
* The rule is Overflow's own helper's: the notes are split into whitespace-
* separated words, each stripped of surrounding punctuation and lower-cased,
* and a keyword fires when it is one of those words. So `give` fires on
* "give", "Give!", "#give" and "(give)", and not on "forgiveness", "giveaway"
* 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.
*/
const ZERO_WIDTH = /[​-‍⁠]/g;
@@ -34,68 +28,39 @@ function norm(s) {
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) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* 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') {
function keywordMatches(notes, keyword) {
const n = cleanNotes(notes);
const k = norm(keyword);
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, ' ');
if (mode === 'anywhere') return flat.includes(k);
const re = new RegExp(`(?:^|[^\\p{L}\\p{N}_])${escapeRe(k)}(?=$|[^\\p{L}\\p{N}_])`, 'u');
return re.test(flat);
}
/** The first mapping (in admin order) whose keyword the notes carry, or null. */
function matchKeyword(notes, mappings, mode = 'word') {
for (const m of mappings || []) {
if (!m || !m.keyword) continue;
if (keywordMatches(notes, m.keyword, mode)) return m;
/** Every keyword the notes carry, in the order the list gives them, each once. Entries may be strings or objects with a `keyword`. */
function matchKeywords(notes, keywords) {
const out = [];
const seen = new Set();
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';
/**
* 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
*/
const assert = require('node:assert/strict');
const { parseKeywordLines, keywordMatches, matchKeyword, cleanNotes } = require('./keywords');
const { keywordMatches, matchKeywords, sameSet, cleanNotes } = require('./keywords');
let n = 0;
function test(name, fn) {
@@ -21,95 +21,56 @@ function test(name, fn) {
}
}
test('parses keyword = destination, with -> and → too', () => {
const m = parseKeywordLines('give = Give Online\nconnect -> Connect Card\nprayer → Prayer Request');
assert.deepEqual(m.map((x) => [x.keyword, x.destination, x.problem]), [
['give', 'Give Online', ''],
['connect', 'Connect Card', ''],
['prayer', 'Prayer Request', ''],
]);
test('a keyword fires as a word of the notes, whatever the case', () => {
assert.equal(keywordMatches('give', 'give'), true);
assert.equal(keywordMatches('Give', 'give'), true);
assert.equal(keywordMatches('please GIVE today', 'give'), true);
});
test('a trailing @ names the groups for that line', () => {
const [m] = parseKeywordLines('connect = Connect Card @ Lobby, Kids Wing');
assert.equal(m.destination, 'Connect Card');
assert.deepEqual(m.groups, ['Lobby', 'Kids Wing']);
test('surrounding punctuation is ignored, as the helper strips it', () => {
assert.equal(keywordMatches('give!', 'give'), true);
assert.equal(keywordMatches('#give now', 'give'), true);
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', () => {
const m = parseKeywordLines('\n# giving\n\ngive = Give Online\n');
assert.equal(m.length, 1);
assert.equal(m[0].line, 4);
test('a keyword inside another word does not fire', () => {
assert.equal(keywordMatches('a sermon on forgiveness', 'give'), false);
assert.equal(keywordMatches('giveaway', 'give'), false);
assert.equal(keywordMatches('give_now', 'give'), false);
});
test('a line without a separator is a problem, not a crash', () => {
const [m] = parseKeywordLines('give');
assert.equal(m.keyword, 'give');
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('a phrase keyword is bounded the same way', () => {
assert.equal(keywordMatches('Please fill out your Next Steps card', 'next steps'), true);
assert.equal(keywordMatches('nextsteps', 'next steps'), false);
});
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', 'anywhere'), true);
assert.equal(keywordMatches('giveXnow', 'give.now', 'anywhere'), false);
assert.equal(keywordMatches('give.now', 'give.now'), true);
assert.equal(keywordMatches('giveXnow', 'give.now'), false);
});
test('empty notes never match; zero-width padding is ignored', () => {
assert.equal(keywordMatches('', 'give'), false);
assert.equal(keywordMatches('​', 'give', 'anywhere'), false);
assert.equal(keywordMatches('​', 'give'), false);
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', () => {
const m = parseKeywordLines('connect = Connect Card\ngive = Give Online');
assert.equal(matchKeyword('give and connect', m).keyword, 'connect');
assert.equal(matchKeyword('just worship', m), null);
assert.equal(matchKeyword('give', []), null);
test('matchKeywords returns every hit, in list order, once each', () => {
const list = ['connect', 'give', 'prayer', { keyword: 'Events' }];
assert.deepEqual(matchKeywords('give and connect, then events', list), ['connect', 'give', 'Events']);
assert.deepEqual(matchKeywords('just worship', list), []);
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)', () => {
const m = parseKeywordLines('baptism');
assert.equal(matchKeyword('baptism', m).problem.length > 0, true);
test('sameSet compares keyword lists regardless of order and case', () => {
assert.equal(sameSet(['give', 'connect'], ['Connect', 'give']), 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}`);
+10 -42
View File
@@ -23,14 +23,11 @@
},
"configGroups": {
"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": {
"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."
},
"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."
"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."
},
"Alerts": {
"toggle": "alertEnabled",
@@ -55,53 +52,24 @@
"default": "",
"help": "Only if a password is set in ProPresenter's network preferences."
},
"clientId": {
"overflowEmail": {
"type": "string",
"label": "Client ID",
"label": "Email",
"group": "Overflow",
"default": ""
},
"apiKey": {
"overflowPassword": {
"type": "password",
"label": "API key",
"label": "Password",
"group": "Overflow",
"default": ""
},
"groups": {
"type": "multiselect",
"label": "Tap groups",
"nonprofitId": {
"type": "string",
"label": "Nonprofit ID",
"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": "",
"help": "One per line — give = Give Online"
},
"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."
"help": "Leave blank: a church login carries its own. Only an @overflow.co staff login needs one."
},
"alertEnabled": {
"type": "switch",
+118 -70
View File
@@ -1,78 +1,104 @@
'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
* Overflow Dashboard's Developers › API Keys. 120 requests per minute per
* client; a 429 is answered when the window is spent.
* Bearer auth on everything after login. A 401 means the session is gone —
* sign in again. The helper's own wording for a refused login is kept here
* (401 invalid email or password, 429 too many attempts, 403 locked).
*
* Reads used here are documented: GET /api/v3/tap/destinations,
* GET /api/v3/tap/groups and GET /api/v3/tap/groups/{id} (a group carries the
* `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.
* OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720 points the client at
* tools/overflow-tap-mock.js.
*/
const DEFAULT_API_BASE = 'https://server.overflow.co';
const API_BASE = String(process.env.OVERFLOW_TAP_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, '');
const DASH = '/nonprofit-dashboard';
const TIMEOUT_MS = 10000;
const PAGE_SIZE = 100;
const MAX_PAGES = 20;
const ID_RE = /^[0-9a-f]{24}$/i;
const TRIGGER_TIMEOUT_MS = 15000;
class OverflowError extends Error {
constructor(message, { status = 0, retryable = false } = {}) {
constructor(message, { status = 0, retryable = false, auth = false } = {}) {
super(message);
this.name = 'OverflowError';
this.status = status;
this.retryable = retryable;
this.auth = auth; // the session was refused: sign in again before retrying
}
}
function looksLikeId(s) {
return ID_RE.test(String(s || '').trim());
/** The JWT's payload, or null when the token is not one. No signature check — we only read our own session. */
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 }) {
const id = String(clientId || '').trim();
const key = String(apiKey || '').trim();
/** What the module needs to know about a session, from its token. */
function describeSession(token) {
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 } = {}) {
if (!id || !key) throw new OverflowError('Overflow client ID and API key are not set', { status: 0 });
function createOverflowClient({ fetchImpl = globalThis.fetch } = {}) {
async function request(method, path, { query, body, token, timeout = TIMEOUT_MS } = {}) {
const url = new URL(API_BASE + path);
for (const [k, v] of Object.entries(query || {})) {
if (v === undefined || v === null || v === '') continue;
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';
const ctl = new AbortController();
const timer = setTimeout(() => ctl.abort(), TIMEOUT_MS);
const timer = setTimeout(() => ctl.abort(), timeout);
let res;
try {
res = await fetchImpl(url, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
signal: ctl.signal,
});
res = await fetchImpl(url, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), signal: ctl.signal });
} catch (err) {
const aborted = err && err.name === 'AbortError';
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 {
clearTimeout(timer);
}
@@ -83,44 +109,66 @@ function createOverflowClient({ clientId, apiKey, fetchImpl = globalThis.fetch }
}
if (!res.ok) {
const detail = data && (data.message || data.error || data.detail);
const what = Array.isArray(detail) ? detail.join('; ') : (detail ? String(detail) : res.statusText);
let message;
if (res.status === 401 || res.status === 403) message = `Overflow refused the credentials (${res.status}${what ? `: ${what}` : ''})`;
else if (res.status === 404 || res.status === 405) message = `Overflow has no ${method} ${path} (${res.status})`;
else if (res.status === 429) message = 'Overflow is rate-limiting (120 requests a minute)';
else message = `Overflow answered ${res.status}${what ? `: ${what}` : ''}`;
throw new OverflowError(message, { status: res.status, retryable: res.status === 429 || res.status >= 500 });
const what = Array.isArray(detail) ? detail.join('; ') : (detail ? String(detail) : '');
throw new OverflowError(what ? `Overflow answered ${res.status}: ${what}` : `Overflow answered ${res.status} ${res.statusText}`.trim(), {
status: res.status,
auth: res.status === 401,
retryable: res.status === 429 || res.status >= 500,
});
}
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 {
base: API_BASE,
configured: Boolean(id && key),
listDestinations: () => listAll('/api/v3/tap/destinations'),
listGroups: () => listAll('/api/v3/tap/groups'),
async getGroup(groupId) {
const res = await request('GET', `/api/v3/tap/groups/${encodeURIComponent(groupId)}`);
return res && res.data ? res.data : res;
/** Sign in with a Dashboard login; resolves to the JWT. */
async login(email, password) {
try {
const data = await request('POST', `${DASH}/authentication/login/jwt`, { body: { email, password } });
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) {
return request('PATCH', `/api/v3/tap/groups/${encodeURIComponent(groupId)}`, { body: { destinationId } });
/** The Keyword Mappings for a nonprofit, as the Dashboard holds them. */
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 };
+299 -203
View File
@@ -4,48 +4,59 @@
* Overflow Tap — server entry.
*
* Does what Overflow's "ProPresenter Integration" helper app does, inside
* ProdDash: watches the live slide in ProPresenter, and when its notes carry
* one of the admin page's keywords, points the chosen Overflow Tap groups at
* that keyword's destination — so what people reach when they tap a disc
* follows what is on screen. A slide with no keyword changes nothing; the
* last destination stays until the next keyworded slide.
* ProdDash: watches the live slide in ProPresenter (the presentation layer
* and the announcement layer), and when its notes carry one of the keywords
* mapped in the Overflow Dashboard, tells Overflow — which points that
* keyword's Tap groups at its destination, so what people reach when they
* tap a disc follows what is on screen. A slide with no keyword changes
* nothing; the last destination stays until the next keyworded slide.
*
* GET /state → the view-model every tile draws
* GET /stream → SSE, `state` events on every change (heartbeated)
* POST /refresh → re-read Overflow's groups and destinations now
* GET /groups → optionsRoute for the admin page's Tap groups picker
* GET /destinations → the destinations, for a quick look while setting up
* POST /refresh → re-read the keyword mappings from the Dashboard now
* GET /keywords → the mappings as read, for a look while setting up
*
* ProPresenter is polled (GET /v1/status/slide, twice a second — one small
* request, nothing heavier) because its chunked update stream is the thing
* that goes quiet without saying so. Overflow is read on start, every few
* minutes after that, and on /refresh; every switch is confirmed by reading
* the group back. Which Overflow call does the switching, and why that call
* is the one unverified piece, is explained at the top of overflow.js.
* ProPresenter is polled (GET /v1/status/slide for the presentation layer,
* GET /v1/announcement/slide_index for announcements, once a second each —
* small requests, nothing heavier) because its chunked update stream is the
* thing that goes quiet without saying so. Overflow is the Dashboard's own
* API, signed in with a Dashboard login — the calls, and where they come
* from, are at the top of overflow.js. Keyword mappings are read on start,
* every few minutes after that, on /refresh, and from every trigger's answer.
*
* OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720 points the module at
* tools/overflow-tap-mock.js, which also plays a ProPresenter on :1602.
*/
const { createOverflowClient, OverflowError, looksLikeId } = require('./overflow');
const { parseKeywordLines, matchKeyword, cleanNotes, norm } = require('./keywords');
const crypto = require('crypto');
const { createOverflowClient, OverflowError, describeSession } = require('./overflow');
const { matchKeywords, sameSet, cleanNotes, norm } = require('./keywords');
const POLL_MS = 500; // ProPresenter, while it answers
const POLL_MS = 1000; // ProPresenter, while it answers
const RETRY_MS = 3000; // ProPresenter, while it does not
const PP_TIMEOUT_MS = 4000;
const HEARTBEAT_MS = 15000;
const DESTINATIONS_REFRESH_MS = 5 * 60 * 1000;
const GROUPS_REFRESH_MS = 60 * 1000; // also how often drift (a change made in Overflow by hand) is noticed
const KEYWORDS_REFRESH_MS = 5 * 60 * 1000;
const OVERFLOW_RETRY_MS = 30 * 1000;
const LOGIN_MIN_GAP_MS = 20 * 1000; // a refused sign-in is not retried sooner (429 = "too many attempts")
const SESSION_MARGIN_MS = 60 * 1000; // sign in again this long before the token expires
const SWITCH_RETRY_MS = 10 * 1000;
const SWITCH_RETRIES = 3;
const HISTORY_MAX = 8; // the tile shows four; a few more for the hover
const ALERT_SECONDS = 30;
const MATCH_MODES = new Set(['word', 'anywhere', 'line']);
/** Set by init(), read by routes() — both are rebuilt together on a config save. */
let current = null;
/**
* The signed-in session, kept across re-inits for the same login: an admin
* saving config five times in a minute must not become five sign-ins
* (Overflow refuses that with 429). Keyed on a hash of the login, so a
* changed email or password starts fresh; the token itself never leaves the
* server process.
*/
let sessionCache = null; // { key, token, expiresAt, email, nonprofitId }
function json(res, code, body) {
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
res.end(JSON.stringify(body));
@@ -60,20 +71,43 @@ function describe(err) {
return cause && !msg.includes(String(cause)) ? `${msg} (${cause})` : msg;
}
function unique(list) {
const seen = new Set();
const out = [];
for (const v of list) {
const k = norm(v);
if (!k || seen.has(k)) continue;
seen.add(k);
out.push(v);
}
return out;
}
/** The destination and group names Overflow attached to a keyword mapping, whatever the field names turn out to be. */
function destinationOf(entry) {
return String((entry && (entry.destinationName || (entry.destination && entry.destination.name))) || '');
}
function groupsOf(entry) {
const list = entry && Array.isArray(entry.tapGroups) ? entry.tapGroups : [];
return list.map((g) => String((g && (g.tapGroupName || g.name || g.id)) || '')).filter(Boolean);
}
module.exports = {
init({ config, log, shell }) {
/* ── config ────────────────────────────────────────────────────── */
const ep = config.propresenter && typeof config.propresenter === 'object' ? config.propresenter : {};
const host = String(ep.host || '').trim();
const port = Number(ep.port) || 1025;
const password = String(config.password || '');
const chosenGroups = (Array.isArray(config.groups) ? config.groups : []).map((g) => String(g || '').trim()).filter(Boolean);
const mappings = parseKeywordLines(config.keywords);
const mode = MATCH_MODES.has(config.matching) ? config.matching : 'word';
const ppPassword = String(config.password || '');
const email = String(config.overflowEmail || '').trim();
const ofPassword = String(config.overflowPassword || '');
const staffNonprofit = String(config.nonprofitId || '').trim();
const configured = Boolean(email && ofPassword);
const alertEnabled = Boolean(config.alertEnabled);
const alertChannel = Math.min(6, Math.max(1, Math.round(Number(config.alertChannel) || 1)));
const sessionKey = crypto.createHash('sha256').update(`${email}\n${ofPassword}`).digest('hex');
const overflow = createOverflowClient({ clientId: config.clientId, apiKey: config.apiKey });
const overflow = createOverflowClient();
/* ── state ─────────────────────────────────────────────────────── */
const streams = new Set();
@@ -81,23 +115,35 @@ module.exports = {
let stopped = false;
let lastFrame = '';
let destinations = []; // Overflow's, as listed
let groups = []; // Overflow's, as listed (each with its current destinationId)
let resolved = []; // the mappings, resolved against those two lists
let lastSlideKey = null; // change detection on the live slide
let wanted = null; // the resolved keyword Tap should be on
let keywordCache = new Map(); // norm(keyword) → the Dashboard's entry
let session = null; // { token, expiresAt, email, nonprofitId }
let lastLoginAt = 0;
let lastLoginError = null;
let wanted = null; // the keywords Tap should be on: { keywords, keyword, destination, groups }
let inFlight = false;
let retriesLeft = 0;
let retryTimer = null;
let alertRaised = false;
// Presentation layer: the slide /v1/status/slide reports.
let lastSlideKey = null;
let lastSlide = null;
let lastPresKeywords = [];
// Announcement layer: the slide /v1/announcement/slide_index points at, read from /v1/announcement/active.
let annSupported = true;
let lastAnnKey = null;
let annNotes = '';
let annLive = false;
let lastAnnKeywords = [];
const state = {
status: 'connecting',
message: '',
propresenter: { configured: Boolean(host), reachable: false, error: '', host, port },
overflow: { configured: overflow.configured, reachable: false, error: '', groups: 0, destinations: 0, loadedAt: 0 },
overflow: { configured, reachable: false, error: '', keywords: 0, loadedAt: 0 },
keywords: [],
slide: { live: false, keyword: '', unmapped: '', hasNotes: false },
slide: { live: false, keywords: [], hasNotes: false },
announcement: { live: false, keywords: [] },
current: null,
history: [],
updatedAt: 0,
@@ -117,30 +163,26 @@ module.exports = {
/* ── health, shared by the admin line and the tiles ────────────── */
function assess() {
function assess(forAdmin = false) {
const pp = state.propresenter;
const of = state.overflow;
if (!pp.configured) return ['error', 'No ProPresenter host configured — set one in /admin'];
if (!of.configured) return ['error', 'Overflow client ID and API key not set — add them in /admin'];
if (!mappings.length) return ['error', 'No keywords configured — add some in /admin'];
if (!of.reachable) return ['error', of.error || 'Reaching Overflow…'];
if (!of.configured) return ['error', 'Overflow login not set — add the email and password in /admin'];
if (!of.reachable) return ['error', of.error || 'Signing in to Overflow…'];
if (!pp.reachable) return ['error', pp.error ? `ProPresenter unreachable: ${pp.error}` : 'Reaching ProPresenter…'];
const bad = state.keywords.filter((k) => k.problem);
if (bad.length && bad.length === state.keywords.length) {
return ['error', `No keyword maps to a destination — ${bad[0].keyword}: ${bad[0].problem}`];
}
if (!state.keywords.length) return ['error', 'No keyword mappings in the Overflow Dashboard (Integrations › ProPresenter)'];
if (state.current && !state.current.pending && !state.current.ok) {
return ['error', `Tap not switched to ${state.current.keyword} — ${state.current.error}`];
}
if (state.current && state.current.pending) return ['connecting', `Switching Tap to ${state.current.keyword}…`];
const extra = bad.length ? ` · ${bad.length} keyword${bad.length === 1 ? '' : 's'} without a destination: ${bad.map((k) => k.keyword).join(', ')}` : '';
const drift = state.current && state.current.drift ? ` · ${state.current.drift}` : '';
return ['ok', `Following ${host}:${port} · Overflow: ${of.groups} group${of.groups === 1 ? '' : 's'}, ${of.destinations} destination${of.destinations === 1 ? '' : 's'}, ${state.keywords.length} keyword${state.keywords.length === 1 ? '' : 's'}${extra}${drift}`];
const n = state.keywords.length;
const who = forAdmin && session && session.email ? `${session.email}, ` : '';
return ['ok', `Following ${host}:${port} · Overflow: ${who}${n} keyword${n === 1 ? '' : 's'}`];
}
function publish() {
if (stopped) return;
[state.status, state.message] = assess();
[state.status, state.message] = assess(false);
const { updatedAt, ...rest } = state;
let frame;
try {
@@ -161,103 +203,106 @@ module.exports = {
}
}
/* ── Overflow: destinations, groups, and the keywords resolved ─── */
/* ── the Overflow session ──────────────────────────────────────── */
function resolveKeywords() {
const dById = new Map(destinations.map((d) => [String(d.id), d]));
const dByName = new Map(destinations.map((d) => [norm(d.name), d]));
const gById = new Map(groups.map((g) => [String(g.id), g]));
const gByName = new Map(groups.map((g) => [norm(g.name), g]));
const defaults = chosenGroups.length
? chosenGroups.map((id) => gById.get(id)).filter(Boolean)
: groups.slice();
resolved = mappings.map((m) => {
const out = { keyword: m.keyword, destination: m.destination, destinationId: '', groups: [], problem: m.problem };
if (out.problem) return out;
const d = (looksLikeId(m.destination) && dById.get(m.destination.trim())) || dByName.get(norm(m.destination));
if (!d) {
out.problem = destinations.length
? `No Overflow destination named "${m.destination}"`
: 'Overflow destinations not read yet';
return out;
async function ensureSession() {
const now = Date.now();
if (session && now < session.expiresAt - SESSION_MARGIN_MS) return session;
if (sessionCache && sessionCache.key === sessionKey && now < sessionCache.expiresAt - SESSION_MARGIN_MS) {
session = sessionCache;
return session;
}
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 (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;
}
if (missing.length) {
out.problem = `No Tap group named ${missing.map((x) => `"${x}"`).join(', ')}`;
return out;
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;
}
} else {
gs = defaults;
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;
}
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;
function dropSession() {
session = null;
if (sessionCache && sessionCache.key === sessionKey) sessionCache = null;
}
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,
/** Run a signed-in call; when the session is refused, sign in again once and retry. */
async function withSession(fn) {
const s = await ensureSession();
try {
return await fn(s);
} catch (err) {
if (!(err instanceof OverflowError) || !err.auth) throw err;
dropSession();
const again = await ensureSession();
return fn(again);
}
}
/* ── the keyword mappings ──────────────────────────────────────── */
function rebuildKeywordView() {
state.keywords = [...keywordCache.values()].map((e) => ({
keyword: String(e.keyword).trim(),
destination: destinationOf(e),
groups: groupsOf(e),
}));
state.overflow.keywords = state.keywords.length;
}
/** 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'}`);
/** 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);
}
cur.drift = moved.length ? `changed in Overflow: ${moved.join('; ')}` : '';
keywordCache = next;
rebuildKeywordView();
}
let destinationsTimer = null;
let groupsTimer = null;
let loading = null;
let refreshTimer = null;
async function loadOverflow(what = 'all') {
if (stopped || !overflow.configured) return;
async function loadKeywords() {
if (stopped || !configured) return;
if (loading) return loading;
loading = (async () => {
try {
if (what === 'all' || what === 'destinations') destinations = await overflow.listDestinations();
groups = await overflow.listGroups();
const list = await withSession((s) => overflow.listKeywords(s.token, s.nonprofitId));
applyKeywordList(list);
state.overflow.reachable = true;
state.overflow.error = '';
state.overflow.groups = groups.length;
state.overflow.destinations = destinations.length;
state.overflow.loadedAt = Date.now();
resolveKeywords();
noteDrift();
// A slide that was already up when the lists arrived (or when a
// destination got its name fixed in Overflow) should take effect now.
if (lastSlideKey !== null) reconsiderSlide();
reconsider(); // a slide already up when the mappings arrive takes effect now
} catch (err) {
state.overflow.reachable = false;
state.overflow.error = describe(err);
log(`Overflow: ${state.overflow.error}`);
if (!resolved.length) resolveKeywords(); // so the keyword list at least shows the lines
}
publish();
})();
@@ -266,24 +311,28 @@ module.exports = {
} finally {
loading = null;
}
scheduleOverflow();
}
function scheduleOverflow() {
if (stopped) return;
if (destinationsTimer) clearTimeout(destinationsTimer);
if (groupsTimer) clearTimeout(groupsTimer);
timers.delete(destinationsTimer);
timers.delete(groupsTimer);
const ok = state.overflow.reachable;
destinationsTimer = later(() => loadOverflow('all'), ok ? DESTINATIONS_REFRESH_MS : OVERFLOW_RETRY_MS);
groupsTimer = ok ? later(() => loadOverflow('groups'), GROUPS_REFRESH_MS) : null;
if (refreshTimer) {
clearTimeout(refreshTimer);
timers.delete(refreshTimer);
}
refreshTimer = later(loadKeywords, state.overflow.reachable ? KEYWORDS_REFRESH_MS : OVERFLOW_RETRY_MS);
}
/* ── the switch ────────────────────────────────────────────────── */
function describeTarget(keys) {
const entries = keys.map((k) => keywordCache.get(norm(k))).filter(Boolean);
return {
keywords: keys,
keyword: keys.join(' + '),
destination: unique(entries.map(destinationOf).filter(Boolean)).join(' + '),
groups: unique(entries.flatMap(groupsOf)),
};
}
function pushHistory(entry) {
const { pending, drift, ...rest } = entry;
const { pending, ...rest } = entry;
state.history.unshift(rest);
if (state.history.length > HISTORY_MAX) state.history.length = HISTORY_MAX;
}
@@ -316,7 +365,7 @@ module.exports = {
if (stopped || inFlight || !wanted) return;
const target = wanted;
const cur = state.current;
const sameTarget = cur && cur.keyword === target.keyword && cur.destination.id === target.destinationId;
const sameTarget = cur && sameSet(cur.keywords, target.keywords);
if (sameTarget && cur.ok) return; // already there
inFlight = true;
let entry;
@@ -326,15 +375,14 @@ module.exports = {
if (cur) pushHistory(cur);
entry = {
keyword: target.keyword,
destination: { id: target.destinationId, name: target.destination },
groups: target.groups.map((g) => g.name),
groupIds: target.groups.map((g) => g.id),
keywords: target.keywords,
destination: target.destination,
groups: target.groups,
at: Date.now(),
ok: false,
pending: true,
error: '',
attempts: 0,
drift: '',
};
state.current = entry;
}
@@ -342,32 +390,23 @@ module.exports = {
entry.attempts += 1;
publish();
try {
for (const g of target.groups) {
if (stopped) return;
await overflow.setGroupDestination(g.id, target.destinationId);
const check = await overflow.getGroup(g.id);
const nowAt = check ? String(check.destinationId || '') : '';
if (check && nowAt !== target.destinationId) {
const name = check.destination && check.destination.name ? `"${check.destination.name}"` : 'another destination';
throw new OverflowError(`Overflow accepted the request but "${g.name}" still points at ${name}`, { status: 200 });
}
const cached = groups.find((x) => String(x.id) === g.id);
if (cached) {
cached.destinationId = target.destinationId;
cached.destination = { id: target.destinationId, name: target.destination };
}
const answer = await withSession((s) => overflow.trigger(s.token, s.nonprofitId, target.keywords));
if (answer) {
applyKeywordList(answer, true);
const fresh = describeTarget(target.keywords);
entry.destination = fresh.destination || entry.destination;
entry.groups = fresh.groups.length ? fresh.groups : entry.groups;
}
entry.ok = true;
entry.error = '';
entry.at = Date.now();
entry.drift = '';
clearAlert();
log(`Tap → ${target.destination} (${target.keyword}) on ${target.groups.map((g) => g.name).join(', ')}`);
log(`Tap → ${entry.destination || '?'} (${target.keyword})${entry.groups.length ? ` on ${entry.groups.join(', ')}` : ''}`);
} catch (err) {
if (stopped) return;
entry.ok = false;
entry.error = describe(err);
log(`Tap not switched to ${target.destination} (${target.keyword}): ${entry.error}`);
log(`Tap not switched to ${target.keyword}: ${entry.error}`);
if (retriesLeft > 0) {
retriesLeft -= 1;
retryTimer = later(() => {
@@ -384,11 +423,38 @@ module.exports = {
}
}
/* ── ProPresenter: the live slide ──────────────────────────────── */
/* ── what is on screen, and what to do about it ────────────────── */
/**
* The helper's rule, per layer: when the set of keywords the layer's
* slide carries changes to a non-empty set, send it. Returning to a
* keyword after a slide without one sends it again; staying on the same
* keyword across slides sends nothing.
*/
function reconsider() {
const list = [...keywordCache.values()];
const presNotes = lastSlide ? cleanNotes(lastSlide.notes) : '';
const presKeys = lastSlide ? matchKeywords(presNotes, list) : [];
state.slide = { live: Boolean(lastSlide), keywords: presKeys, hasNotes: Boolean(presNotes.trim()) };
const presChanged = !sameSet(presKeys, lastPresKeywords);
if (presChanged) lastPresKeywords = presKeys;
const annKeys = annLive ? matchKeywords(annNotes, list) : [];
state.announcement = { live: annLive, keywords: annKeys };
const annChanged = !sameSet(annKeys, lastAnnKeywords);
if (annChanged) lastAnnKeywords = annKeys;
const batch = [];
if (presChanged && presKeys.length) batch.push(...presKeys);
if (annChanged && annKeys.length) batch.push(...annKeys);
if (batch.length) requestSwitch(describeTarget(unique(batch)));
}
/* ── ProPresenter ──────────────────────────────────────────────── */
function ppUrl(target) {
const u = new URL(`http://${host}:${port}${target}`);
if (password) u.searchParams.set('password', password);
if (ppPassword) u.searchParams.set('password', ppPassword);
return u;
}
@@ -397,7 +463,11 @@ module.exports = {
const timer = setTimeout(() => ctl.abort(), PP_TIMEOUT_MS);
try {
const res = await fetch(ppUrl(target), { headers: { Accept: 'application/json' }, signal: ctl.signal });
if (!res.ok) throw new Error(`ProPresenter ${res.status} ${res.statusText}`);
if (!res.ok) {
const err = new Error(`ProPresenter ${res.status} ${res.statusText}`);
err.status = res.status;
throw err;
}
const text = await res.text();
return text ? JSON.parse(text) : null;
} finally {
@@ -405,62 +475,96 @@ module.exports = {
}
}
let lastSlide = null;
function reconsiderSlide() {
const cur = lastSlide;
if (!cur) {
state.slide = { live: false, keyword: '', unmapped: '', hasNotes: false };
return;
}
const notes = cleanNotes(cur.notes);
const hit = matchKeyword(notes, resolved, mode);
state.slide = {
live: true,
keyword: hit ? hit.keyword : '',
unmapped: hit && hit.problem ? hit.problem : '',
hasNotes: Boolean(notes.trim()),
};
if (hit && !hit.problem) requestSwitch(hit);
}
/** The presentation layer: /v1/status/slide names the live slide and carries its notes. */
async function pollSlide() {
if (stopped) return;
let ok = false;
try {
const data = await ppJson('/v1/status/slide');
const cur = data && data.current && typeof data.current === 'object' ? data.current : null;
const key = cur ? `${cur.uuid || ''}\u0000${cur.text || ''}\u0000${cur.notes || ''}` : '';
if (key === lastSlideKey) return false;
lastSlideKey = key;
lastSlide = cur;
return true;
}
/**
* The announcement layer: its slide index names the document and the
* slide; the document's slides (every group's, in order, disabled ones
* included — that is the index's space) carry the notes. An older
* ProPresenter without this API answers 404 once and is left alone.
*/
async function pollAnnouncement() {
if (!annSupported) return false;
let idx;
try {
idx = await ppJson('/v1/announcement/slide_index');
} catch (err) {
if (err && err.status === 404) {
annSupported = false;
log('this ProPresenter has no announcement layer API — following the presentation layer only');
return false;
}
throw err;
}
const node = idx && idx.announcement_index;
if (!node || !Number.isInteger(node.index)) {
if (lastAnnKey === '') return false;
lastAnnKey = '';
annLive = false;
annNotes = '';
return true;
}
const id = node.presentation_id || {};
const key = `${id.uuid || ''}\u0000${id.name || ''}\u0000${node.index}`;
if (key === lastAnnKey) return false;
const doc = await ppJson('/v1/announcement/active');
const ann = doc && doc.announcement;
const slides = [];
for (const g of (ann && Array.isArray(ann.groups) ? ann.groups : [])) {
for (const s of (g && Array.isArray(g.slides) ? g.slides : [])) slides.push(s);
}
const slide = slides[node.index];
annNotes = slide ? cleanNotes(slide.notes) : '';
annLive = true;
lastAnnKey = key;
return true;
}
async function pollTick() {
if (stopped) return;
let ok = false;
let changed = false;
try {
changed = await pollSlide();
state.propresenter.reachable = true;
state.propresenter.error = '';
ok = true;
const key = cur ? `${cur.uuid || ''}\u0000${cur.text || ''}\u0000${cur.notes || ''}` : '';
if (key !== lastSlideKey) {
lastSlideKey = key;
lastSlide = cur;
reconsiderSlide();
try {
if (await pollAnnouncement()) changed = true;
} catch {
/* the presentation poll already said ProPresenter answers; a hiccup here waits for the next tick */
}
} catch (err) {
state.propresenter.reachable = false;
state.propresenter.error = err && err.name === 'AbortError' ? 'timed out' : describe(err);
}
if (changed) reconsider();
publish();
later(pollSlide, ok ? POLL_MS : RETRY_MS);
later(pollTick, ok ? POLL_MS : RETRY_MS);
}
/* ── go ────────────────────────────────────────────────────────── */
resolveKeywords();
if (host) {
pollSlide();
pollTick();
log(`following ProPresenter at ${host}:${port}`);
} else {
log('no ProPresenter host configured — set one in /admin');
}
if (overflow.configured) {
loadOverflow('all');
if (configured) {
loadKeywords();
log(`Overflow at ${overflow.base}`);
} else {
log('Overflow client ID / API key not set — add them in /admin');
log('Overflow login not set — add the email and password in /admin');
}
publish();
@@ -477,11 +581,9 @@ module.exports = {
const handle = {
streams,
configured,
getState: () => state,
refresh: () => loadOverflow('all'),
groupOptions: () => groups.map((g) => ({ value: String(g.id), label: String(g.name || g.id) })),
destinationOptions: () => destinations.map((d) => ({ value: String(d.id), label: String(d.name || d.id) })),
configured: overflow.configured,
refresh: () => loadKeywords(),
};
current = handle;
@@ -499,7 +601,7 @@ module.exports = {
if (current === handle) current = null;
},
health() {
const [status, message] = assess();
const [status, message] = assess(true);
return { status, message };
},
};
@@ -529,23 +631,17 @@ module.exports = {
'POST /refresh': async (req, res) => {
if (!current) return json(res, 503, { error: 'Overflow Tap is not running.' });
if (!current.configured) return json(res, 400, { error: 'Overflow client ID and API key are not set.' });
if (!current.configured) return json(res, 400, { error: 'Overflow login is not set.' });
await current.refresh();
const st = current.getState();
if (!st.overflow.reachable) return json(res, 502, { error: st.overflow.error || 'Overflow unreachable.' });
json(res, 200, { ok: true, groups: st.overflow.groups, destinations: st.overflow.destinations });
json(res, 200, { ok: true, keywords: st.overflow.keywords });
},
'GET /groups': async (req, res) => {
if (!current) return json(res, 200, { options: [] });
if (current.configured && !current.groupOptions().length) await current.refresh();
json(res, 200, { options: current.groupOptions() });
},
'GET /destinations': async (req, res) => {
if (!current) return json(res, 200, { options: [] });
if (current.configured && !current.destinationOptions().length) await current.refresh();
json(res, 200, { options: current.destinationOptions() });
'GET /keywords': (req, res) => {
if (!current) return json(res, 503, { error: 'Overflow Tap is not running.' });
const st = current.getState();
json(res, 200, { keywords: st.keywords, loadedAt: st.overflow.loadedAt, error: st.overflow.error });
},
};
},
+182 -110
View File
@@ -1,46 +1,55 @@
/**
* Mock ProPresenter slide status + mock Overflow Tap API, for developing the
* overflow-tap module without a live ProPresenter or an Overflow account.
* Mock ProPresenter slide status + mock Overflow Dashboard API, for developing
* the overflow-tap module without a live ProPresenter or an Overflow account.
*
* 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
* uses, with a service that advances every --step ms:
* ProPresenter side (127.0.0.1:1602 by default) — the routes the module uses,
* with a service that advances every --step ms:
* GET /version reachability probe
* GET /v1/status/slide { current: { uuid, text, notes }, next }, cycling
* through slides whose notes carry keywords
* ("give", "Next steps: connect", "prayer"), slides
* with other notes ("forgiveness" — must NOT fire
* "give" in whole-word mode), a slide whose keyword
* maps to nothing on the account, and a stretch with
* nothing live.
* ("give", "Next steps: connect", "prayer"),
* slides with other notes ("forgiveness" —
* must NOT fire "give"), a slide whose
* keyword is not mapped on the account, and
* 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_TAP_API_BASE=http://127.0.0.1:24720) — the documented reads with
* paging, and the switch the module sends:
* GET /api/v3/tap/destinations four destinations
* GET /api/v3/tap/groups three groups, each pointing somewhere
* GET /api/v3/tap/groups/{id}
* PATCH /api/v3/tap/groups/{id} { destinationId } — moves the group.
* --read-only answers 404 instead, which
* is what Overflow's public API does
* today, to exercise the failure path.
* Every request must carry x-client-id and x-api-key (any non-empty values;
* --key=<apiKey> demands that exact key, answering 401 otherwise). --slow adds
* a two-second delay to every Overflow answer.
*
* Suggested admin Keywords for this mock:
* give = Give Online
* connect = Connect Card @ Lobby
* prayer = Prayer Request
* baptism = Baptism Signup
* OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720) — the Dashboard API the
* module speaks (see modules/overflow-tap/overflow.js):
* POST /nonprofit-dashboard/authentication/login/jwt any non-empty email +
* password signs in (--login=<email>:<password> demands that pair,
* answering 401 otherwise); the token expires after --expire seconds
* (3600 by default — set 20 to exercise the sign-in-again path). An
* @overflow.co email gets a staff token with no nonprofit of its own.
* GET /nonprofit-dashboard/propresenter/nonprofits staff: the nonprofits
* POST /nonprofit-dashboard/propresenter/nonprofits/{id}/select
* GET /nonprofit-dashboard/propresenter/nonprofits/{id}/keywords four mappings:
* give → Give Online (every group), connect → Connect Card (Lobby),
* prayer → Prayer Request (every group), events → Events (Lobby, Kids
* Wing). "baptism" is deliberately unmapped.
* POST /nonprofit-dashboard/propresenter/nonprofits/{id}/keywords?syncNonprofit=true
* { keywords } — moves the groups and answers the updated list.
* --fail-switch answers 500 instead, to exercise the failure path.
* Every signed-in call needs a Bearer token this mock issued and has not
* 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.
*/
'use strict';
const http = require('http');
const crypto = require('crypto');
const args = process.argv.slice(2);
const opt = (name, dflt) => {
@@ -50,9 +59,16 @@ const opt = (name, dflt) => {
const PP_PORT = Number.parseInt(opt('pp', '1602'), 10);
const OF_PORT = Number.parseInt(opt('overflow', '24720'), 10);
const STEP_MS = Number.parseInt(opt('step', '4000'), 10);
const KEY = opt('key', '');
const READ_ONLY = args.includes('--read-only');
const LOGIN = opt('login', '');
const EXPIRE_S = Number.parseInt(opt('expire', '3600'), 10);
const FAIL_SWITCH = args.includes('--fail-switch');
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 ────────────────────────────────────────────────── */
@@ -68,116 +84,172 @@ const SLIDES = [
{ uuid: 'S8', text: 'Give online (again)', notes: 'give' },
{ uuid: 'S9', text: 'See you next week', notes: '' },
];
let slideIdx = 0;
setInterval(() => { slideIdx = (slideIdx + 1) % SLIDES.length; }, STEP_MS).unref();
const ANNOUNCEMENT = {
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() {
const cur = SLIDES[slideIdx];
const nxt = SLIDES[(slideIdx + 1) % SLIDES.length];
const cur = SLIDES[slideIdx()];
const nxt = SLIDES[(slideIdx() + 1) % SLIDES.length];
return {
current: cur ? { uuid: cur.uuid, text: cur.text, notes: cur.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) => {
const url = new URL(req.url, `http://${req.headers.host}`);
const p = url.pathname;
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 === '/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' });
}).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 ────────────────────────────────────────────────────── */
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 }));
/* ── Overflow Dashboard ──────────────────────────────────────────── */
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 = [
{ id: '6710f34fd5061afeec3eab11', name: 'Auditorium', destinationId: DESTINATIONS[3].id, deviceIds: ['6710f34fd5061afeec3eab21', '6710f34fd5061afeec3eab22'] },
{ id: '6710f34fd5061afeec3eab12', name: 'Lobby', destinationId: DESTINATIONS[3].id, deviceIds: ['6710f34fd5061afeec3eab23'] },
{ id: '6710f34fd5061afeec3eab13', name: 'Kids Wing', destinationId: DESTINATIONS[1].id, deviceIds: [] },
].map((g) => ({ ...g, createdAt: stamp('2026-05-01T17:22:11Z'), updatedAt: stamp('2026-05-04T14:09:32Z'), archivedAt: null }));
{ id: '6710f34fd5061afeec3eab11', tapGroupName: 'Auditorium', destinationId: DESTINATIONS.events.id },
{ id: '6710f34fd5061afeec3eab12', tapGroupName: 'Lobby', destinationId: DESTINATIONS.events.id },
{ id: '6710f34fd5061afeec3eab13', tapGroupName: 'Kids Wing', destinationId: DESTINATIONS.connect.id },
];
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 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 groupView(g) {
return { ...g, destination: destView(g.destinationId) };
}
function page(list, url) {
const limit = Math.min(100, Math.max(1, Number(url.searchParams.get('limit')) || 25));
const pg = Math.max(1, Number(url.searchParams.get('page')) || 1);
const start = (pg - 1) * limit;
return { data: list.slice(start, start + limit), totalCount: list.length };
}
const tokens = new Map(); // token → { email, exp, nonprofitId }
let served = 0;
http.createServer((req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`);
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 go = () => sendJson(res, code, body, rl);
if (SLOW) setTimeout(go, 2000);
else go();
function issueToken(email) {
const staff = email.toLowerCase().endsWith('@overflow.co');
const now = Math.floor(Date.now() / 1000);
const payload = {
id: 'user-1',
email,
fullName: 'Mock User',
role: staff ? 'overflowSupport' : 'orgAdmin',
permissions: [{ action: 'manage', subject: 'TAP' }],
nonprofits: staff ? [] : [{ id: NONPROFIT.id, name: NONPROFIT.name, slug: NONPROFIT.slug }],
iat: now,
exp: now + EXPIRE_S,
};
const cid = String(req.headers['x-client-id'] || '');
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 (!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;
}
if (req.method === 'GET' && p === '/api/v3/tap/destinations') return answer(200, page(DESTINATIONS, url));
const dOne = /^\/api\/v3\/tap\/destinations\/([^/]+)$/.exec(p);
if (req.method === 'GET' && dOne) {
const d = DESTINATIONS.find((x) => x.id === dOne[1]);
return d ? answer(200, { data: d }) : answer(404, { statusCode: 404, message: 'Destination not found' });
}
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);
if (gOne) {
const g = GROUPS.find((x) => x.id === gOne[1]);
if (!g) return answer(404, { statusCode: 404, message: 'Group not found' });
if (req.method === 'GET') return answer(200, { data: groupView(g) });
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}` });
function bearer(req) {
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', () => {
let parsed = {};
try { parsed = JSON.parse(body || '{}'); } catch { return answer(400, { statusCode: 400, message: 'Invalid JSON' }); }
const dest = DESTINATIONS.find((x) => x.id === parsed.destinationId);
if (!dest) return answer(400, { statusCode: 400, message: 'destinationId must be an existing destination' });
g.destinationId = dest.id;
g.updatedAt = stamp(Date.now());
console.log(`[overflow] ${g.name} → ${dest.name}`);
answer(200, { data: groupView(g) });
try { resolve(JSON.parse(body || '{}')); } catch { resolve(null); }
});
return;
});
}
http.createServer(async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`);
const p = url.pathname;
const answer = (code, body) => {
const go = () => sendJson(res, code, body);
if (SLOW) setTimeout(go, 2000);
else go();
};
console.log(`[overflow] ${req.method} ${p}${url.search}`);
if (req.method === 'POST' && p === '/nonprofit-dashboard/authentication/login/jwt') {
const body = await readBody(req);
const email = String((body && body.email) || '').trim();
const password = String((body && body.password) || '');
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) });
}
const session = bearer(req);
if (!session) return answer(401, { statusCode: 401, message: 'Unauthorized' });
if (req.method === 'GET' && p === '/nonprofit-dashboard/propresenter/nonprofits') {
return answer(200, { data: { nonprofits: [NONPROFIT] } });
}
const sel = /^\/nonprofit-dashboard\/propresenter\/nonprofits\/([^/]+)\/select$/.exec(p);
if (req.method === 'POST' && sel) {
if (sel[1] !== NONPROFIT.id) return answer(404, { statusCode: 404, message: 'Nonprofit not found' });
session.nonprofitId = NONPROFIT.id;
return answer(204);
}
const kw = /^\/nonprofit-dashboard\/propresenter\/nonprofits\/([^/]+)\/keywords$/.exec(p);
if (kw) {
if (kw[1] !== NONPROFIT.id || session.nonprofitId !== NONPROFIT.id) return answer(403, { statusCode: 403, message: 'Forbidden' });
if (req.method === 'GET') return answer(200, stampedKeywords());
if (req.method === 'POST') {
const body = await readBody(req);
const keys = body && Array.isArray(body.keywords) ? body.keywords.map((k) => String(k).toLowerCase().trim()) : [];
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}` });
}).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)` : ''}`);
});