diff --git a/CLAUDE.md b/CLAUDE.md index a21ee7f..e18e45f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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=:`). ## Reference code (read-only forks — don't develop these here) diff --git a/README.md b/README.md index 47e18b1..b93d5c9 100644 --- a/README.md +++ b/README.md @@ -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=:` accepts +one login only). See the testing section of the +[module guide](docs/MODULE-GUIDE.md). ## Install as a desktop web app diff --git a/modules/overflow-tap/client.js b/modules/overflow-tap/client.js index 29f3a46..dee1221 100644 --- a/modules/overflow-tap/client.js +++ b/modules/overflow-tap/client.js @@ -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', { diff --git a/modules/overflow-tap/keywords.js b/modules/overflow-tap/keywords.js index 6727b9a..a9a8c49 100644 --- a/modules/overflow-tap/keywords.js +++ b/modules/overflow-tap/keywords.js @@ -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 + * 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 }; diff --git a/modules/overflow-tap/keywords.selftest.js b/modules/overflow-tap/keywords.selftest.js index 049dde8..4c71352 100644 --- a/modules/overflow-tap/keywords.selftest.js +++ b/modules/overflow-tap/keywords.selftest.js @@ -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}`); diff --git a/modules/overflow-tap/module.json b/modules/overflow-tap/module.json index 4fec966..2c2ff78 100644 --- a/modules/overflow-tap/module.json +++ b/modules/overflow-tap/module.json @@ -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", diff --git a/modules/overflow-tap/overflow.js b/modules/overflow-tap/overflow.js index 346df7c..d2eb9e3 100644 --- a/modules/overflow-tap/overflow.js +++ b/modules/overflow-tap/overflow.js @@ -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 }; diff --git a/modules/overflow-tap/server.js b/modules/overflow-tap/server.js index 149f3ff..3036a76 100644 --- a/modules/overflow-tap/server.js +++ b/modules/overflow-tap/server.js @@ -4,48 +4,59 @@ * Overflow Tap — server entry. * * Does what Overflow's "ProPresenter Integration" helper app does, inside - * ProdDash: watches the live slide in ProPresenter, and when its notes carry - * one of the admin page's keywords, points the chosen Overflow Tap groups at - * that keyword's destination — so what people reach when they tap a disc - * follows what is on screen. A slide with no keyword changes nothing; the - * last destination stays until the next keyworded slide. + * ProdDash: watches the live slide in ProPresenter (the presentation layer + * and the announcement layer), and when its notes carry one of the keywords + * mapped in the Overflow Dashboard, tells Overflow — which points that + * keyword's Tap groups at its destination, so what people reach when they + * tap a disc follows what is on screen. A slide with no keyword changes + * nothing; the last destination stays until the next keyworded slide. * - * GET /state → the view-model every tile draws - * GET /stream → SSE, `state` events on every change (heartbeated) - * POST /refresh → re-read Overflow's groups and destinations now - * GET /groups → optionsRoute for the admin page's Tap groups picker - * GET /destinations → the destinations, for a quick look while setting up + * GET /state → the view-model every tile draws + * GET /stream → SSE, `state` events on every change (heartbeated) + * POST /refresh → re-read the keyword mappings from the Dashboard now + * GET /keywords → the mappings as read, for a look while setting up * - * ProPresenter is polled (GET /v1/status/slide, twice a second — one small - * request, nothing heavier) because its chunked update stream is the thing - * that goes quiet without saying so. Overflow is read on start, every few - * minutes after that, and on /refresh; every switch is confirmed by reading - * the group back. Which Overflow call does the switching, and why that call - * is the one unverified piece, is explained at the top of overflow.js. + * ProPresenter is polled (GET /v1/status/slide for the presentation layer, + * GET /v1/announcement/slide_index for announcements, once a second each — + * small requests, nothing heavier) because its chunked update stream is the + * thing that goes quiet without saying so. Overflow is the Dashboard's own + * API, signed in with a Dashboard login — the calls, and where they come + * from, are at the top of overflow.js. Keyword mappings are read on start, + * every few minutes after that, on /refresh, and from every trigger's answer. * * OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720 points the module at * tools/overflow-tap-mock.js, which also plays a ProPresenter on :1602. */ -const { createOverflowClient, OverflowError, looksLikeId } = require('./overflow'); -const { parseKeywordLines, matchKeyword, cleanNotes, norm } = require('./keywords'); +const crypto = require('crypto'); +const { createOverflowClient, OverflowError, describeSession } = require('./overflow'); +const { matchKeywords, sameSet, cleanNotes, norm } = require('./keywords'); -const POLL_MS = 500; // ProPresenter, while it answers -const RETRY_MS = 3000; // ProPresenter, while it does not +const POLL_MS = 1000; // ProPresenter, while it answers +const RETRY_MS = 3000; // ProPresenter, while it does not const PP_TIMEOUT_MS = 4000; const HEARTBEAT_MS = 15000; -const DESTINATIONS_REFRESH_MS = 5 * 60 * 1000; -const GROUPS_REFRESH_MS = 60 * 1000; // also how often drift (a change made in Overflow by hand) is noticed +const KEYWORDS_REFRESH_MS = 5 * 60 * 1000; const OVERFLOW_RETRY_MS = 30 * 1000; +const LOGIN_MIN_GAP_MS = 20 * 1000; // a refused sign-in is not retried sooner (429 = "too many attempts") +const SESSION_MARGIN_MS = 60 * 1000; // sign in again this long before the token expires const SWITCH_RETRY_MS = 10 * 1000; const SWITCH_RETRIES = 3; const HISTORY_MAX = 8; // the tile shows four; a few more for the hover const ALERT_SECONDS = 30; -const MATCH_MODES = new Set(['word', 'anywhere', 'line']); /** Set by init(), read by routes() — both are rebuilt together on a config save. */ let current = null; +/** + * The signed-in session, kept across re-inits for the same login: an admin + * saving config five times in a minute must not become five sign-ins + * (Overflow refuses that with 429). Keyed on a hash of the login, so a + * changed email or password starts fresh; the token itself never leaves the + * server process. + */ +let sessionCache = null; // { key, token, expiresAt, email, nonprofitId } + function json(res, code, body) { res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }); res.end(JSON.stringify(body)); @@ -60,20 +71,43 @@ function describe(err) { return cause && !msg.includes(String(cause)) ? `${msg} (${cause})` : msg; } +function unique(list) { + const seen = new Set(); + const out = []; + for (const v of list) { + const k = norm(v); + if (!k || seen.has(k)) continue; + seen.add(k); + out.push(v); + } + return out; +} + +/** The destination and group names Overflow attached to a keyword mapping, whatever the field names turn out to be. */ +function destinationOf(entry) { + return String((entry && (entry.destinationName || (entry.destination && entry.destination.name))) || ''); +} +function groupsOf(entry) { + const list = entry && Array.isArray(entry.tapGroups) ? entry.tapGroups : []; + return list.map((g) => String((g && (g.tapGroupName || g.name || g.id)) || '')).filter(Boolean); +} + module.exports = { init({ config, log, shell }) { /* ── config ────────────────────────────────────────────────────── */ const ep = config.propresenter && typeof config.propresenter === 'object' ? config.propresenter : {}; const host = String(ep.host || '').trim(); const port = Number(ep.port) || 1025; - const password = String(config.password || ''); - const chosenGroups = (Array.isArray(config.groups) ? config.groups : []).map((g) => String(g || '').trim()).filter(Boolean); - const mappings = parseKeywordLines(config.keywords); - const mode = MATCH_MODES.has(config.matching) ? config.matching : 'word'; + const ppPassword = String(config.password || ''); + const email = String(config.overflowEmail || '').trim(); + const ofPassword = String(config.overflowPassword || ''); + const staffNonprofit = String(config.nonprofitId || '').trim(); + const configured = Boolean(email && ofPassword); const alertEnabled = Boolean(config.alertEnabled); const alertChannel = Math.min(6, Math.max(1, Math.round(Number(config.alertChannel) || 1))); + const sessionKey = crypto.createHash('sha256').update(`${email}\n${ofPassword}`).digest('hex'); - const overflow = createOverflowClient({ clientId: config.clientId, apiKey: config.apiKey }); + const overflow = createOverflowClient(); /* ── state ─────────────────────────────────────────────────────── */ const streams = new Set(); @@ -81,23 +115,35 @@ module.exports = { let stopped = false; let lastFrame = ''; - let destinations = []; // Overflow's, as listed - let groups = []; // Overflow's, as listed (each with its current destinationId) - let resolved = []; // the mappings, resolved against those two lists - let lastSlideKey = null; // change detection on the live slide - let wanted = null; // the resolved keyword Tap should be on + let keywordCache = new Map(); // norm(keyword) → the Dashboard's entry + let session = null; // { token, expiresAt, email, nonprofitId } + let lastLoginAt = 0; + let lastLoginError = null; + let wanted = null; // the keywords Tap should be on: { keywords, keyword, destination, groups } let inFlight = false; let retriesLeft = 0; let retryTimer = null; let alertRaised = false; + // Presentation layer: the slide /v1/status/slide reports. + let lastSlideKey = null; + let lastSlide = null; + let lastPresKeywords = []; + // Announcement layer: the slide /v1/announcement/slide_index points at, read from /v1/announcement/active. + let annSupported = true; + let lastAnnKey = null; + let annNotes = ''; + let annLive = false; + let lastAnnKeywords = []; + const state = { status: 'connecting', message: '', propresenter: { configured: Boolean(host), reachable: false, error: '', host, port }, - overflow: { configured: overflow.configured, reachable: false, error: '', groups: 0, destinations: 0, loadedAt: 0 }, + overflow: { configured, reachable: false, error: '', keywords: 0, loadedAt: 0 }, keywords: [], - slide: { live: false, keyword: '', unmapped: '', hasNotes: false }, + slide: { live: false, keywords: [], hasNotes: false }, + announcement: { live: false, keywords: [] }, current: null, history: [], updatedAt: 0, @@ -117,30 +163,26 @@ module.exports = { /* ── health, shared by the admin line and the tiles ────────────── */ - function assess() { + function assess(forAdmin = false) { const pp = state.propresenter; const of = state.overflow; if (!pp.configured) return ['error', 'No ProPresenter host configured — set one in /admin']; - if (!of.configured) return ['error', 'Overflow client ID and API key not set — add them in /admin']; - if (!mappings.length) return ['error', 'No keywords configured — add some in /admin']; - if (!of.reachable) return ['error', of.error || 'Reaching Overflow…']; + if (!of.configured) return ['error', 'Overflow login not set — add the email and password in /admin']; + if (!of.reachable) return ['error', of.error || 'Signing in to Overflow…']; if (!pp.reachable) return ['error', pp.error ? `ProPresenter unreachable: ${pp.error}` : 'Reaching ProPresenter…']; - const bad = state.keywords.filter((k) => k.problem); - if (bad.length && bad.length === state.keywords.length) { - return ['error', `No keyword maps to a destination — ${bad[0].keyword}: ${bad[0].problem}`]; - } + if (!state.keywords.length) return ['error', 'No keyword mappings in the Overflow Dashboard (Integrations › ProPresenter)']; if (state.current && !state.current.pending && !state.current.ok) { return ['error', `Tap not switched to ${state.current.keyword} — ${state.current.error}`]; } if (state.current && state.current.pending) return ['connecting', `Switching Tap to ${state.current.keyword}…`]; - const extra = bad.length ? ` · ${bad.length} keyword${bad.length === 1 ? '' : 's'} without a destination: ${bad.map((k) => k.keyword).join(', ')}` : ''; - const drift = state.current && state.current.drift ? ` · ${state.current.drift}` : ''; - return ['ok', `Following ${host}:${port} · Overflow: ${of.groups} group${of.groups === 1 ? '' : 's'}, ${of.destinations} destination${of.destinations === 1 ? '' : 's'}, ${state.keywords.length} keyword${state.keywords.length === 1 ? '' : 's'}${extra}${drift}`]; + const n = state.keywords.length; + const who = forAdmin && session && session.email ? `${session.email}, ` : ''; + return ['ok', `Following ${host}:${port} · Overflow: ${who}${n} keyword${n === 1 ? '' : 's'}`]; } function publish() { if (stopped) return; - [state.status, state.message] = assess(); + [state.status, state.message] = assess(false); const { updatedAt, ...rest } = state; let frame; try { @@ -161,103 +203,106 @@ module.exports = { } } - /* ── Overflow: destinations, groups, and the keywords resolved ─── */ + /* ── the Overflow session ──────────────────────────────────────── */ - function resolveKeywords() { - const dById = new Map(destinations.map((d) => [String(d.id), d])); - const dByName = new Map(destinations.map((d) => [norm(d.name), d])); - const gById = new Map(groups.map((g) => [String(g.id), g])); - const gByName = new Map(groups.map((g) => [norm(g.name), g])); - const defaults = chosenGroups.length - ? chosenGroups.map((id) => gById.get(id)).filter(Boolean) - : groups.slice(); - resolved = mappings.map((m) => { - const out = { keyword: m.keyword, destination: m.destination, destinationId: '', groups: [], problem: m.problem }; - if (out.problem) return out; - const d = (looksLikeId(m.destination) && dById.get(m.destination.trim())) || dByName.get(norm(m.destination)); - if (!d) { - out.problem = destinations.length - ? `No Overflow destination named "${m.destination}"` - : 'Overflow destinations not read yet'; - return out; - } - out.destination = String(d.name || m.destination); - out.destinationId = String(d.id); - let gs; - if (m.groups.length) { - gs = []; - const missing = []; - for (const name of m.groups) { - const g = (looksLikeId(name) && gById.get(name)) || gByName.get(norm(name)); - if (g) gs.push(g); - else missing.push(name); - } - if (missing.length) { - out.problem = `No Tap group named ${missing.map((x) => `"${x}"`).join(', ')}`; - return out; - } - } else { - gs = defaults; - } - if (!gs.length) { - out.problem = groups.length - ? 'None of the chosen Tap groups exist any more' - : 'No Tap groups on this Overflow account'; - return out; - } - out.groups = gs.map((g) => ({ id: String(g.id), name: String(g.name || g.id) })); - return out; - }); - state.keywords = resolved.map((r) => ({ - keyword: r.keyword, - destination: r.destination, - groups: r.groups.map((g) => g.name), - problem: r.problem, - })); - } - - /** Has anyone moved a group we switched? Noted on the live entry, never fought. */ - function noteDrift() { - const cur = state.current; - if (!cur || !cur.ok || cur.pending) return; - const byId = new Map(groups.map((g) => [String(g.id), g])); - const moved = []; - for (const g of cur.groupIds || []) { - const now = byId.get(g); - if (!now) continue; - if (String(now.destinationId || '') !== cur.destination.id) { - moved.push(`${now.name} now points at ${now.destination && now.destination.name ? now.destination.name : 'another destination'}`); - } + async function ensureSession() { + const now = Date.now(); + if (session && now < session.expiresAt - SESSION_MARGIN_MS) return session; + if (sessionCache && sessionCache.key === sessionKey && now < sessionCache.expiresAt - SESSION_MARGIN_MS) { + session = sessionCache; + return session; } - cur.drift = moved.length ? `changed in Overflow: ${moved.join('; ')}` : ''; + if (lastLoginError && now - lastLoginAt < LOGIN_MIN_GAP_MS) throw lastLoginError; + lastLoginAt = now; + let token; + try { + token = await overflow.login(email, ofPassword); + } catch (err) { + lastLoginError = err; + throw err; + } + lastLoginError = null; + const info = describeSession(token); + if (!info.hasTap) throw new OverflowError('This Overflow login has no Tap permission — use one with the Organization Admin or Tap Only role'); + let nonprofitId = info.nonprofitId || (info.nonprofits.length === 1 ? info.nonprofits[0].id : ''); + if (info.staff) { + if (!staffNonprofit) throw new OverflowError('An @overflow.co login has no nonprofit of its own — enter the Nonprofit ID in /admin'); + await overflow.selectNonprofit(token, staffNonprofit); + nonprofitId = staffNonprofit; + } else if (!nonprofitId) { + nonprofitId = staffNonprofit; + } + if (!nonprofitId) throw new OverflowError('This Overflow login carries no nonprofit — enter the Nonprofit ID in /admin'); + session = { token, expiresAt: info.expiresAt || now + 12 * 3600 * 1000, email: info.email || email, nonprofitId }; + sessionCache = { key: sessionKey, ...session }; + log(`signed in to Overflow as ${session.email} (nonprofit ${nonprofitId})`); + return session; } - let destinationsTimer = null; - let groupsTimer = null; - let loading = null; + function dropSession() { + session = null; + if (sessionCache && sessionCache.key === sessionKey) sessionCache = null; + } - async function loadOverflow(what = 'all') { - if (stopped || !overflow.configured) return; + /** Run a signed-in call; when the session is refused, sign in again once and retry. */ + async function withSession(fn) { + const s = await ensureSession(); + try { + return await fn(s); + } catch (err) { + if (!(err instanceof OverflowError) || !err.auth) throw err; + dropSession(); + const again = await ensureSession(); + return fn(again); + } + } + + /* ── the keyword mappings ──────────────────────────────────────── */ + + function rebuildKeywordView() { + state.keywords = [...keywordCache.values()].map((e) => ({ + keyword: String(e.keyword).trim(), + destination: destinationOf(e), + groups: groupsOf(e), + })); + state.overflow.keywords = state.keywords.length; + } + + /** Replace the cache with the Dashboard's list (`merge` folds a partial answer into it instead). */ + function applyKeywordList(list, merge = false) { + const next = merge ? new Map(keywordCache) : new Map(); + for (const entry of list || []) { + if (!entry || typeof entry !== 'object') continue; + const k = norm(entry.keyword); + if (!k) continue; + if (entry.active === false || entry.enabled === false || entry.isActive === false) { + next.delete(k); + continue; + } + next.set(k, entry); + } + keywordCache = next; + rebuildKeywordView(); + } + + let loading = null; + let refreshTimer = null; + + async function loadKeywords() { + if (stopped || !configured) return; if (loading) return loading; loading = (async () => { try { - if (what === 'all' || what === 'destinations') destinations = await overflow.listDestinations(); - groups = await overflow.listGroups(); + const list = await withSession((s) => overflow.listKeywords(s.token, s.nonprofitId)); + applyKeywordList(list); state.overflow.reachable = true; state.overflow.error = ''; - state.overflow.groups = groups.length; - state.overflow.destinations = destinations.length; state.overflow.loadedAt = Date.now(); - resolveKeywords(); - noteDrift(); - // A slide that was already up when the lists arrived (or when a - // destination got its name fixed in Overflow) should take effect now. - if (lastSlideKey !== null) reconsiderSlide(); + reconsider(); // a slide already up when the mappings arrive takes effect now } catch (err) { state.overflow.reachable = false; state.overflow.error = describe(err); log(`Overflow: ${state.overflow.error}`); - if (!resolved.length) resolveKeywords(); // so the keyword list at least shows the lines } publish(); })(); @@ -266,24 +311,28 @@ module.exports = { } finally { loading = null; } - scheduleOverflow(); - } - - function scheduleOverflow() { if (stopped) return; - if (destinationsTimer) clearTimeout(destinationsTimer); - if (groupsTimer) clearTimeout(groupsTimer); - timers.delete(destinationsTimer); - timers.delete(groupsTimer); - const ok = state.overflow.reachable; - destinationsTimer = later(() => loadOverflow('all'), ok ? DESTINATIONS_REFRESH_MS : OVERFLOW_RETRY_MS); - groupsTimer = ok ? later(() => loadOverflow('groups'), GROUPS_REFRESH_MS) : null; + if (refreshTimer) { + clearTimeout(refreshTimer); + timers.delete(refreshTimer); + } + refreshTimer = later(loadKeywords, state.overflow.reachable ? KEYWORDS_REFRESH_MS : OVERFLOW_RETRY_MS); } /* ── the switch ────────────────────────────────────────────────── */ + function describeTarget(keys) { + const entries = keys.map((k) => keywordCache.get(norm(k))).filter(Boolean); + return { + keywords: keys, + keyword: keys.join(' + '), + destination: unique(entries.map(destinationOf).filter(Boolean)).join(' + '), + groups: unique(entries.flatMap(groupsOf)), + }; + } + function pushHistory(entry) { - const { pending, drift, ...rest } = entry; + const { pending, ...rest } = entry; state.history.unshift(rest); if (state.history.length > HISTORY_MAX) state.history.length = HISTORY_MAX; } @@ -316,7 +365,7 @@ module.exports = { if (stopped || inFlight || !wanted) return; const target = wanted; const cur = state.current; - const sameTarget = cur && cur.keyword === target.keyword && cur.destination.id === target.destinationId; + const sameTarget = cur && sameSet(cur.keywords, target.keywords); if (sameTarget && cur.ok) return; // already there inFlight = true; let entry; @@ -326,15 +375,14 @@ module.exports = { if (cur) pushHistory(cur); entry = { keyword: target.keyword, - destination: { id: target.destinationId, name: target.destination }, - groups: target.groups.map((g) => g.name), - groupIds: target.groups.map((g) => g.id), + keywords: target.keywords, + destination: target.destination, + groups: target.groups, at: Date.now(), ok: false, pending: true, error: '', attempts: 0, - drift: '', }; state.current = entry; } @@ -342,32 +390,23 @@ module.exports = { entry.attempts += 1; publish(); try { - for (const g of target.groups) { - if (stopped) return; - await overflow.setGroupDestination(g.id, target.destinationId); - const check = await overflow.getGroup(g.id); - const nowAt = check ? String(check.destinationId || '') : ''; - if (check && nowAt !== target.destinationId) { - const name = check.destination && check.destination.name ? `"${check.destination.name}"` : 'another destination'; - throw new OverflowError(`Overflow accepted the request but "${g.name}" still points at ${name}`, { status: 200 }); - } - const cached = groups.find((x) => String(x.id) === g.id); - if (cached) { - cached.destinationId = target.destinationId; - cached.destination = { id: target.destinationId, name: target.destination }; - } + const answer = await withSession((s) => overflow.trigger(s.token, s.nonprofitId, target.keywords)); + if (answer) { + applyKeywordList(answer, true); + const fresh = describeTarget(target.keywords); + entry.destination = fresh.destination || entry.destination; + entry.groups = fresh.groups.length ? fresh.groups : entry.groups; } entry.ok = true; entry.error = ''; entry.at = Date.now(); - entry.drift = ''; clearAlert(); - log(`Tap → ${target.destination} (${target.keyword}) on ${target.groups.map((g) => g.name).join(', ')}`); + log(`Tap → ${entry.destination || '?'} (${target.keyword})${entry.groups.length ? ` on ${entry.groups.join(', ')}` : ''}`); } catch (err) { if (stopped) return; entry.ok = false; entry.error = describe(err); - log(`Tap not switched to ${target.destination} (${target.keyword}): ${entry.error}`); + log(`Tap not switched to ${target.keyword}: ${entry.error}`); if (retriesLeft > 0) { retriesLeft -= 1; retryTimer = later(() => { @@ -384,11 +423,38 @@ module.exports = { } } - /* ── ProPresenter: the live slide ──────────────────────────────── */ + /* ── what is on screen, and what to do about it ────────────────── */ + + /** + * The helper's rule, per layer: when the set of keywords the layer's + * slide carries changes to a non-empty set, send it. Returning to a + * keyword after a slide without one sends it again; staying on the same + * keyword across slides sends nothing. + */ + function reconsider() { + const list = [...keywordCache.values()]; + const presNotes = lastSlide ? cleanNotes(lastSlide.notes) : ''; + const presKeys = lastSlide ? matchKeywords(presNotes, list) : []; + state.slide = { live: Boolean(lastSlide), keywords: presKeys, hasNotes: Boolean(presNotes.trim()) }; + const presChanged = !sameSet(presKeys, lastPresKeywords); + if (presChanged) lastPresKeywords = presKeys; + + const annKeys = annLive ? matchKeywords(annNotes, list) : []; + state.announcement = { live: annLive, keywords: annKeys }; + const annChanged = !sameSet(annKeys, lastAnnKeywords); + if (annChanged) lastAnnKeywords = annKeys; + + const batch = []; + if (presChanged && presKeys.length) batch.push(...presKeys); + if (annChanged && annKeys.length) batch.push(...annKeys); + if (batch.length) requestSwitch(describeTarget(unique(batch))); + } + + /* ── ProPresenter ──────────────────────────────────────────────── */ function ppUrl(target) { const u = new URL(`http://${host}:${port}${target}`); - if (password) u.searchParams.set('password', password); + if (ppPassword) u.searchParams.set('password', ppPassword); return u; } @@ -397,7 +463,11 @@ module.exports = { const timer = setTimeout(() => ctl.abort(), PP_TIMEOUT_MS); try { const res = await fetch(ppUrl(target), { headers: { Accept: 'application/json' }, signal: ctl.signal }); - if (!res.ok) throw new Error(`ProPresenter ${res.status} ${res.statusText}`); + if (!res.ok) { + const err = new Error(`ProPresenter ${res.status} ${res.statusText}`); + err.status = res.status; + throw err; + } const text = await res.text(); return text ? JSON.parse(text) : null; } finally { @@ -405,62 +475,96 @@ module.exports = { } } - let lastSlide = null; - - function reconsiderSlide() { - const cur = lastSlide; - if (!cur) { - state.slide = { live: false, keyword: '', unmapped: '', hasNotes: false }; - return; - } - const notes = cleanNotes(cur.notes); - const hit = matchKeyword(notes, resolved, mode); - state.slide = { - live: true, - keyword: hit ? hit.keyword : '', - unmapped: hit && hit.problem ? hit.problem : '', - hasNotes: Boolean(notes.trim()), - }; - if (hit && !hit.problem) requestSwitch(hit); + /** The presentation layer: /v1/status/slide names the live slide and carries its notes. */ + async function pollSlide() { + const data = await ppJson('/v1/status/slide'); + const cur = data && data.current && typeof data.current === 'object' ? data.current : null; + const key = cur ? `${cur.uuid || ''}\u0000${cur.text || ''}\u0000${cur.notes || ''}` : ''; + if (key === lastSlideKey) return false; + lastSlideKey = key; + lastSlide = cur; + return true; } - async function pollSlide() { + /** + * The announcement layer: its slide index names the document and the + * slide; the document's slides (every group's, in order, disabled ones + * included — that is the index's space) carry the notes. An older + * ProPresenter without this API answers 404 once and is left alone. + */ + async function pollAnnouncement() { + if (!annSupported) return false; + let idx; + try { + idx = await ppJson('/v1/announcement/slide_index'); + } catch (err) { + if (err && err.status === 404) { + annSupported = false; + log('this ProPresenter has no announcement layer API — following the presentation layer only'); + return false; + } + throw err; + } + const node = idx && idx.announcement_index; + if (!node || !Number.isInteger(node.index)) { + if (lastAnnKey === '') return false; + lastAnnKey = ''; + annLive = false; + annNotes = ''; + return true; + } + const id = node.presentation_id || {}; + const key = `${id.uuid || ''}\u0000${id.name || ''}\u0000${node.index}`; + if (key === lastAnnKey) return false; + const doc = await ppJson('/v1/announcement/active'); + const ann = doc && doc.announcement; + const slides = []; + for (const g of (ann && Array.isArray(ann.groups) ? ann.groups : [])) { + for (const s of (g && Array.isArray(g.slides) ? g.slides : [])) slides.push(s); + } + const slide = slides[node.index]; + annNotes = slide ? cleanNotes(slide.notes) : ''; + annLive = true; + lastAnnKey = key; + return true; + } + + async function pollTick() { if (stopped) return; let ok = false; + let changed = false; try { - const data = await ppJson('/v1/status/slide'); - const cur = data && data.current && typeof data.current === 'object' ? data.current : null; + changed = await pollSlide(); state.propresenter.reachable = true; state.propresenter.error = ''; ok = true; - const key = cur ? `${cur.uuid || ''}\u0000${cur.text || ''}\u0000${cur.notes || ''}` : ''; - if (key !== lastSlideKey) { - lastSlideKey = key; - lastSlide = cur; - reconsiderSlide(); + try { + if (await pollAnnouncement()) changed = true; + } catch { + /* the presentation poll already said ProPresenter answers; a hiccup here waits for the next tick */ } } catch (err) { state.propresenter.reachable = false; state.propresenter.error = err && err.name === 'AbortError' ? 'timed out' : describe(err); } + if (changed) reconsider(); publish(); - later(pollSlide, ok ? POLL_MS : RETRY_MS); + later(pollTick, ok ? POLL_MS : RETRY_MS); } /* ── go ────────────────────────────────────────────────────────── */ - resolveKeywords(); if (host) { - pollSlide(); + pollTick(); log(`following ProPresenter at ${host}:${port}`); } else { log('no ProPresenter host configured — set one in /admin'); } - if (overflow.configured) { - loadOverflow('all'); + if (configured) { + loadKeywords(); log(`Overflow at ${overflow.base}`); } else { - log('Overflow client ID / API key not set — add them in /admin'); + log('Overflow login not set — add the email and password in /admin'); } publish(); @@ -477,11 +581,9 @@ module.exports = { const handle = { streams, + configured, getState: () => state, - refresh: () => loadOverflow('all'), - groupOptions: () => groups.map((g) => ({ value: String(g.id), label: String(g.name || g.id) })), - destinationOptions: () => destinations.map((d) => ({ value: String(d.id), label: String(d.name || d.id) })), - configured: overflow.configured, + refresh: () => loadKeywords(), }; current = handle; @@ -499,7 +601,7 @@ module.exports = { if (current === handle) current = null; }, health() { - const [status, message] = assess(); + const [status, message] = assess(true); return { status, message }; }, }; @@ -529,23 +631,17 @@ module.exports = { 'POST /refresh': async (req, res) => { if (!current) return json(res, 503, { error: 'Overflow Tap is not running.' }); - if (!current.configured) return json(res, 400, { error: 'Overflow client ID and API key are not set.' }); + if (!current.configured) return json(res, 400, { error: 'Overflow login is not set.' }); await current.refresh(); const st = current.getState(); if (!st.overflow.reachable) return json(res, 502, { error: st.overflow.error || 'Overflow unreachable.' }); - json(res, 200, { ok: true, groups: st.overflow.groups, destinations: st.overflow.destinations }); + json(res, 200, { ok: true, keywords: st.overflow.keywords }); }, - 'GET /groups': async (req, res) => { - if (!current) return json(res, 200, { options: [] }); - if (current.configured && !current.groupOptions().length) await current.refresh(); - json(res, 200, { options: current.groupOptions() }); - }, - - 'GET /destinations': async (req, res) => { - if (!current) return json(res, 200, { options: [] }); - if (current.configured && !current.destinationOptions().length) await current.refresh(); - json(res, 200, { options: current.destinationOptions() }); + 'GET /keywords': (req, res) => { + if (!current) return json(res, 503, { error: 'Overflow Tap is not running.' }); + const st = current.getState(); + json(res, 200, { keywords: st.keywords, loadedAt: st.overflow.loadedAt, error: st.overflow.error }); }, }; }, diff --git a/tools/overflow-tap-mock.js b/tools/overflow-tap-mock.js index 024c4f4..fa1d54b 100644 --- a/tools/overflow-tap-mock.js +++ b/tools/overflow-tap-mock.js @@ -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=] [--read-only] [--slow] + * [--login=:] [--expire=] + * [--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: - * 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. + * 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"), 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= 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=: 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 } + +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, + }; + 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; -http.createServer((req, res) => { +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', () => { + try { resolve(JSON.parse(body || '{}')); } catch { resolve(null); } + }); + }); +} + +http.createServer(async (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); + const go = () => sendJson(res, code, body); if (SLOW) setTimeout(go, 2000); else go(); }; - 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' }); + console.log(`[overflow] ${req.method} ${p}${url.search}`); - 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 === '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) }); } - 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}` }); - 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) }); - }); - return; + + 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)` : ''}`); });