diff --git a/CLAUDE.md b/CLAUDE.md index 572c2c3..e18e45f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,6 +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 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 2fe2a0f..b93d5c9 100644 --- a/README.md +++ b/README.md @@ -510,6 +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 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. @@ -560,8 +581,15 @@ timers + timecode on `127.0.0.1:1600`), `node tools/pco-mock.js` run ProdDash with `SLACK_API_BASE=http://127.0.0.1:24716/api`) and `node tools/pco-band-mock.js 24710` (Planning Center for the Band Lineup board — a plan, its team members, birthdays and photos — run ProdDash with -`BAND_LINEUP_API_BASE=http://127.0.0.1:24710`). See the testing section of -the [module guide](docs/MODULE-GUIDE.md). +`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`, 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 new file mode 100644 index 0000000..dee1221 --- /dev/null +++ b/modules/overflow-tap/client.js @@ -0,0 +1,233 @@ +/* Overflow Tap — the tile. + * + * One fact matters across a booth: which keyword Tap is on right now, and + * whether the switch actually landed. That is the big line. Under it, the + * destination it maps to and the groups it moved; a small line says whether + * the slide on screen carries a keyword at all; and the last four switches + * sit at the bottom with their times, so a tech can see the service's trail. + * + * Everything comes from the server's `state` stream. The tile never talks to + * ProPresenter or Overflow itself. + */ + +const HISTORY_SHOWN = 4; + +const ICON_REFRESH = ''; + +export default function create({ root, moduleApi }) { + let state = null; + let stream = null; + let refreshBtn = null; + let streamDown = false; // the shared ProdDash stream dropped; cleared by the next state + + const settings = () => moduleApi.instanceSettings || {}; + const on = (key) => settings()[key] !== false; + + function el(tag, className, text) { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined && text !== null) node.textContent = text; + return node; + } + + function clock(ms) { + if (!ms) return ''; + try { + return new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + } catch { + return ''; + } + } + + /** The one-line reason the tile cannot do its job, or '' when it can. */ + function blocker() { + if (!state) return 'Waiting for ProdDash'; + 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 login — add it in /admin'; + if (of.reachable && !(state.keywords || []).length) return 'No keyword mappings in the Overflow Dashboard yet'; + return ''; + } + + function render() { + root.textContent = ''; + const box = el('div', 'ot-root'); + root.append(box); + + const stop = blocker(); + if (stop) { + box.append(el('div', 'ot-empty', stop)); + return; + } + + const pp = state.propresenter || {}; + const of = state.overflow || {}; + const cur = state.current; + const offline = streamDown || !pp.reachable || !of.reachable; + + if (offline) { + box.classList.add('is-offline'); + const why = streamDown ? 'ProdDash connection lost' + : !of.reachable ? (of.error || 'Overflow unreachable') + : (pp.error ? `ProPresenter unreachable — ${pp.error}` : 'ProPresenter unreachable'); + box.append(el('div', 'ot-offline', why)); + } + + const main = el('div', 'ot-main'); + 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, 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 = ''; + if (cur.pending) { + label = cur.attempts > 1 ? `SWITCHING · attempt ${cur.attempts}` : 'SWITCHING'; + tone = 'muted'; + } else if (!cur.ok) { + label = `NOT SWITCHED · ${clock(cur.at)}`; + tone = 'alert'; + note = cur.error || ''; + } + 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 || '')); + 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'; + main.append(el('div', 'ot-label', 'NO KEYWORD YET')); + main.append(el('div', 'ot-keyword is-none', '—')); + main.append(el('div', 'ot-dest', 'Tap stays where Overflow left it')); + } + box.append(main); + + 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 && !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)); + } + + if (on('showHistory')) { + const rows = (state.history || []).slice(0, HISTORY_SHOWN); + const list = el('ol', 'ot-history'); + if (!rows.length) { + list.append(el('li', 'ot-h-empty', cur ? 'No earlier switches' : 'No switches yet')); + } + 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.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 || '')); + row.append(el('span', 'ot-h-mark', h.ok ? '✓' : '✕')); + list.append(row); + } + box.append(list); + } + } + + function applyStatus() { + if (!state) return moduleApi.setStatus('connecting', 'Waiting for ProdDash'); + const s = state.status === 'ok' || state.status === 'connecting' || state.status === 'error' ? state.status : 'connecting'; + moduleApi.setStatus(s, state.message || ''); + } + + function handleState(e) { + try { + state = JSON.parse(e.data); + } catch { + return; + } + streamDown = false; + applyStatus(); + render(); + } + + async function backfill() { + try { + const res = await moduleApi.fetch('/state'); + if (!res.ok) return; + const body = await res.json(); + if (!body || !body.state || !stream) return; + state = body.state; + streamDown = false; + applyStatus(); + render(); + } catch { + /* the stream will bring the next state */ + } + } + + async function refresh() { + if (!refreshBtn) return; + refreshBtn.classList.add('active'); + try { + const res = await moduleApi.fetch('/refresh', { method: 'POST' }); + if (!res.ok) { + 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', `Keyword refresh failed: ${err && err.message ? err.message : err}`); + } finally { + refreshBtn.classList.remove('active'); + } + } + + return { + start() { + moduleApi.setStatus('connecting', 'Connecting'); + refreshBtn = moduleApi.header.addButton({ + icon: ICON_REFRESH, + title: 'Refresh keywords', + onClick: refresh, + }); + stream = moduleApi.sse('/stream', { + // A fresh connection replays the state; a tile joining a stream the + // page already carries gets only `open`, and the server sends state + // on change alone — so backfill, or a second tile waits for the next + // switch to draw anything. + open() { backfill(); }, + error() { + moduleApi.setStatus('error', 'Lost the connection to ProdDash'); + streamDown = true; + render(); + }, + events: { state: handleState }, + }); + render(); + }, + + stop() { + if (stream) stream.close(); + stream = null; + refreshBtn = null; + root.textContent = ''; + }, + + onConfigChange() { + render(); + }, + }; +} diff --git a/modules/overflow-tap/keywords.js b/modules/overflow-tap/keywords.js new file mode 100644 index 0000000..a9a8c49 --- /dev/null +++ b/modules/overflow-tap/keywords.js @@ -0,0 +1,66 @@ +'use strict'; + +/** + * 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: + * + * 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 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; + +function cleanNotes(raw) { + return String(raw ?? '').replace(ZERO_WIDTH, '').replace(/\r\n?/g, '\n'); +} + +function norm(s) { + return String(s ?? '').trim().toLowerCase().replace(/\s+/g, ' '); +} + +function escapeRe(s) { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function keywordMatches(notes, keyword) { + const n = cleanNotes(notes); + const k = norm(keyword); + if (!k || !n.trim()) return false; + const flat = n.toLowerCase().replace(/\s+/g, ' '); + const re = new RegExp(`(?:^|[^\\p{L}\\p{N}_])${escapeRe(k)}(?=$|[^\\p{L}\\p{N}_])`, 'u'); + return re.test(flat); +} + +/** 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 out; +} + +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 new file mode 100644 index 0000000..4c71352 --- /dev/null +++ b/modules/overflow-tap/keywords.selftest.js @@ -0,0 +1,76 @@ +'use strict'; + +/** + * Self-test for keywords.js — the matching rules, pinned. + * + * node modules/overflow-tap/keywords.selftest.js + */ + +const assert = require('node:assert/strict'); +const { keywordMatches, matchKeywords, sameSet, cleanNotes } = require('./keywords'); + +let n = 0; +function test(name, fn) { + n += 1; + try { + fn(); + console.log(`ok ${name}`); + } catch (err) { + console.log(`FAIL ${name}\n ${err.message}`); + process.exitCode = 1; + } +} + +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('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('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 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('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'), false); + assert.equal(cleanNotes('​give'), 'give'); + assert.equal(keywordMatches('​give', 'give'), true); +}); + +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('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 new file mode 100644 index 0000000..2c2ff78 --- /dev/null +++ b/modules/overflow-tap/module.json @@ -0,0 +1,112 @@ +{ + "id": "overflow-tap", + "name": "Overflow Tap", + "version": "1.0.0", + "proddash": ">=1.11.0", + "description": "Switches Overflow Tap destinations from keywords in ProPresenter slide notes", + "permissions": [ + { + "kind": "local-network", + "reason": "Reaching ProPresenter on this network" + } + ], + "client": "client.js", + "server": "server.js", + "style": "style.css", + "minSize": { + "w": 2, + "h": 2 + }, + "defaultSize": { + "w": 4, + "h": 3 + }, + "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 (and the announcement layer's) are read — nothing on the stage screens changes." + }, + "Overflow": { + "collapsed": "whenSet", + "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", + "collapsed": true, + "help": "Flash the dashboards listening on this channel when a switch fails — Overflow unreachable or refusing the change — so someone can fix Tap by hand before the moment passes." + } + }, + "configSchema": { + "propresenter": { + "type": "endpoint", + "label": "ProPresenter", + "group": "ProPresenter", + "default": { + "host": "", + "port": 1025 + } + }, + "password": { + "type": "password", + "label": "API password", + "group": "ProPresenter", + "default": "", + "help": "Only if a password is set in ProPresenter's network preferences." + }, + "overflowEmail": { + "type": "string", + "label": "Email", + "group": "Overflow", + "default": "" + }, + "overflowPassword": { + "type": "password", + "label": "Password", + "group": "Overflow", + "default": "" + }, + "nonprofitId": { + "type": "string", + "label": "Nonprofit ID", + "group": "Overflow", + "default": "", + "help": "Leave blank: a church login carries its own. Only an @overflow.co staff login needs one." + }, + "alertEnabled": { + "type": "switch", + "label": "Alert when a switch fails", + "group": "Alerts", + "default": false + }, + "alertChannel": { + "type": "number", + "label": "Channel", + "group": "Alerts", + "default": 1, + "min": 1, + "max": 6, + "help": "1–6; each dashboard chooses the channels it listens to in its Layouts menu." + } + }, + "instanceSchema": { + "showDestination": { + "type": "switch", + "label": "Destination", + "default": true + }, + "showGroups": { + "type": "switch", + "label": "Groups", + "default": true + }, + "showSlide": { + "type": "switch", + "label": "Slide match", + "default": true + }, + "showHistory": { + "type": "switch", + "label": "History", + "default": true + } + } +} diff --git a/modules/overflow-tap/overflow.js b/modules/overflow-tap/overflow.js new file mode 100644 index 0000000..d2eb9e3 --- /dev/null +++ b/modules/overflow-tap/overflow.js @@ -0,0 +1,174 @@ +'use strict'; + +/** + * 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). + * + * 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 + * + * 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). + * + * 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 TRIGGER_TIMEOUT_MS = 15000; + +class OverflowError extends Error { + 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 + } +} + +/** 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; + } +} + +/** 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, + }; +} + +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' }; + if (token) headers.Authorization = `Bearer ${token}`; + if (body !== undefined) headers['Content-Type'] = 'application/json'; + const ctl = new AbortController(); + 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 }); + } 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 / 1000} s` : `Overflow unreachable: ${err.message}${cause ? ` (${cause})` : ''}`, + { retryable: true }, + ); + } finally { + clearTimeout(timer); + } + const text = await res.text(); + let data = null; + if (text) { + try { data = JSON.parse(text); } catch { data = null; } + } + if (!res.ok) { + const detail = data && (data.message || data.error || data.detail); + 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; + } + + return { + base: API_BASE, + + /** 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; + } + }, + + /** 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, decodeJwt, describeSession, API_BASE, DEFAULT_API_BASE }; diff --git a/modules/overflow-tap/server.js b/modules/overflow-tap/server.js new file mode 100644 index 0000000..3036a76 --- /dev/null +++ b/modules/overflow-tap/server.js @@ -0,0 +1,648 @@ +'use strict'; + +/** + * Overflow Tap — server entry. + * + * Does what Overflow's "ProPresenter Integration" helper app does, inside + * 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 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 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 crypto = require('crypto'); +const { createOverflowClient, OverflowError, describeSession } = require('./overflow'); +const { matchKeywords, sameSet, cleanNotes, norm } = require('./keywords'); + +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 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; + +/** 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)); +} + +/** One line for a log or a tile; a fetch failure names its cause (ECONNREFUSED, ENOTFOUND…). */ +function describe(err) { + if (err instanceof OverflowError) return err.message; + if (err && err.name === 'AbortError') return 'timed out'; + const msg = err && err.message ? err.message : String(err); + const cause = err && err.cause && (err.cause.code || err.cause.message); + 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 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(); + + /* ── state ─────────────────────────────────────────────────────── */ + const streams = new Set(); + const timers = new Set(); + let stopped = false; + let lastFrame = ''; + + 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, reachable: false, error: '', keywords: 0, loadedAt: 0 }, + keywords: [], + slide: { live: false, keywords: [], hasNotes: false }, + announcement: { live: false, keywords: [] }, + current: null, + history: [], + updatedAt: 0, + }; + + /* ── timers that die with the handle ───────────────────────────── */ + + function later(fn, ms) { + const t = setTimeout(() => { + timers.delete(t); + if (!stopped) fn(); + }, ms); + t.unref?.(); + timers.add(t); + return t; + } + + /* ── health, shared by the admin line and the tiles ────────────── */ + + 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 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…']; + 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 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(false); + const { updatedAt, ...rest } = state; + let frame; + try { + frame = JSON.stringify(rest); + } catch { + return; + } + if (frame === lastFrame) return; + lastFrame = frame; + state.updatedAt = Date.now(); + const payload = `event: state\ndata: ${JSON.stringify(state)}\n\n`; + for (const res of streams) { + try { + res.write(payload); + } catch { + streams.delete(res); + } + } + } + + /* ── the Overflow session ──────────────────────────────────────── */ + + 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; + } + 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; + } + + function dropSession() { + session = null; + if (sessionCache && sessionCache.key === sessionKey) sessionCache = null; + } + + /** 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 { + const list = await withSession((s) => overflow.listKeywords(s.token, s.nonprofitId)); + applyKeywordList(list); + state.overflow.reachable = true; + state.overflow.error = ''; + state.overflow.loadedAt = Date.now(); + 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}`); + } + publish(); + })(); + try { + await loading; + } finally { + loading = null; + } + if (stopped) return; + 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, ...rest } = entry; + state.history.unshift(rest); + if (state.history.length > HISTORY_MAX) state.history.length = HISTORY_MAX; + } + + function raiseAlert(keyword) { + if (!alertEnabled || !shell || typeof shell.alert !== 'function') return; + alertRaised = true; + Promise.resolve(shell.alert({ channel: alertChannel, level: 'medium', text: `Tap not switched: ${keyword}`, duration: ALERT_SECONDS })) + .catch((err) => log(`alert refused: ${describe(err)}`)); + } + + function clearAlert() { + if (!alertRaised || !shell || typeof shell.clearAlert !== 'function') return; + alertRaised = false; + Promise.resolve(shell.clearAlert(alertChannel)).catch(() => {}); + } + + function requestSwitch(target) { + wanted = target; + retriesLeft = SWITCH_RETRIES; + if (retryTimer) { + clearTimeout(retryTimer); + timers.delete(retryTimer); + retryTimer = null; + } + runSwitch(); + } + + async function runSwitch() { + if (stopped || inFlight || !wanted) return; + const target = wanted; + const cur = state.current; + const sameTarget = cur && sameSet(cur.keywords, target.keywords); + if (sameTarget && cur.ok) return; // already there + inFlight = true; + let entry; + if (sameTarget) { + entry = cur; // another go at the same switch + } else { + if (cur) pushHistory(cur); + entry = { + keyword: target.keyword, + keywords: target.keywords, + destination: target.destination, + groups: target.groups, + at: Date.now(), + ok: false, + pending: true, + error: '', + attempts: 0, + }; + state.current = entry; + } + entry.pending = true; + entry.attempts += 1; + publish(); + try { + 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(); + clearAlert(); + 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.keyword}: ${entry.error}`); + if (retriesLeft > 0) { + retriesLeft -= 1; + retryTimer = later(() => { + retryTimer = null; + runSwitch(); + }, SWITCH_RETRY_MS); + } + raiseAlert(target.keyword); + } finally { + entry.pending = false; + inFlight = false; + publish(); + if (!stopped && wanted !== target) runSwitch(); // a newer keyword arrived meanwhile + } + } + + /* ── 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 (ppPassword) u.searchParams.set('password', ppPassword); + return u; + } + + async function ppJson(target) { + const ctl = new AbortController(); + 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) { + 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 { + clearTimeout(timer); + } + } + + /** 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; + } + + /** + * 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; + 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(pollTick, ok ? POLL_MS : RETRY_MS); + } + + /* ── go ────────────────────────────────────────────────────────── */ + + if (host) { + pollTick(); + log(`following ProPresenter at ${host}:${port}`); + } else { + log('no ProPresenter host configured — set one in /admin'); + } + if (configured) { + loadKeywords(); + log(`Overflow at ${overflow.base}`); + } else { + log('Overflow login not set — add the email and password in /admin'); + } + publish(); + + const heartbeat = setInterval(() => { + for (const res of streams) { + try { + res.write(': ping\n\n'); + } catch { + streams.delete(res); + } + } + }, HEARTBEAT_MS); + heartbeat.unref?.(); + + const handle = { + streams, + configured, + getState: () => state, + refresh: () => loadKeywords(), + }; + current = handle; + + return { + stop() { + stopped = true; + clearInterval(heartbeat); + for (const t of timers) clearTimeout(t); + timers.clear(); + clearAlert(); + for (const res of streams) { + try { res.end(); } catch { /* already gone */ } + } + streams.clear(); + if (current === handle) current = null; + }, + health() { + const [status, message] = assess(true); + return { status, message }; + }, + }; + }, + + routes() { + return { + 'GET /state': (req, res) => { + if (!current) return json(res, 503, { error: 'Overflow Tap is not running.' }); + json(res, 200, { state: current.getState() }); + }, + + 'GET /stream': (req, res) => { + res.writeHead(200, { + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }); + res.write('retry: 3000\n\n'); + if (!current) return void res.end(); + res.write(`event: state\ndata: ${JSON.stringify(current.getState())}\n\n`); + try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ } + current.streams.add(res); + req.on('close', () => current && current.streams.delete(res)); + }, + + '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 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, keywords: st.overflow.keywords }); + }, + + '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/modules/overflow-tap/style.css b/modules/overflow-tap/style.css new file mode 100644 index 0000000..9a43d86 --- /dev/null +++ b/modules/overflow-tap/style.css @@ -0,0 +1,213 @@ +/* Overflow Tap — scoped under its tile via [data-module="overflow-tap"] and + styled with the shell's variables only. Sizes hang off the tile (container + units) so the keyword reads from across a booth on a big tile and still + fits a 2×2. */ + +[data-module="overflow-tap"] { + container-type: size; +} + +[data-module="overflow-tap"] .ot-root { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: stretch; + padding: clamp(4px, 4cqmin, 18px); + gap: clamp(2px, 1.8cqmin, 10px); + overflow: hidden; + color: var(--text); + user-select: none; + -webkit-user-select: none; +} + +[data-module="overflow-tap"] .ot-offline { + flex-shrink: 0; + font-size: clamp(0.6rem, 4.6cqmin, 0.9rem); + font-weight: 700; + color: var(--danger); + text-transform: uppercase; + letter-spacing: 0.04em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +[data-module="overflow-tap"] .ot-root.is-offline .ot-main, +[data-module="overflow-tap"] .ot-root.is-offline .ot-slide, +[data-module="overflow-tap"] .ot-root.is-offline .ot-history { + opacity: 0.45; + filter: grayscale(0.6); +} + +/* ── the live keyword ───────────────────────────────────────────── */ + +[data-module="overflow-tap"] .ot-main { + --tone: var(--accent); + flex: 1 1 auto; + min-height: 0; + container-type: size; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0; + text-align: center; + overflow: hidden; +} +[data-module="overflow-tap"] .ot-main[data-tone="ok"] { --tone: var(--accent); } +[data-module="overflow-tap"] .ot-main[data-tone="warn"] { --tone: var(--warn); } +[data-module="overflow-tap"] .ot-main[data-tone="alert"] { --tone: var(--danger); } +[data-module="overflow-tap"] .ot-main[data-tone="muted"] { --tone: var(--muted); } + +[data-module="overflow-tap"] .ot-label { + color: var(--tone); + font-size: clamp(0.5rem, 9cqh, 0.95rem); + font-weight: 700; + letter-spacing: 0.14em; + line-height: 1.2; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +[data-module="overflow-tap"] .ot-keyword { + color: var(--tone); + font-size: min(clamp(1rem, 38cqh, 7rem), 18cqw); + font-weight: 750; + line-height: 1.15; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +[data-module="overflow-tap"] .ot-keyword.is-none { color: var(--muted); } +[data-module="overflow-tap"] .ot-main[data-tone="muted"] .ot-keyword { + animation: ot-pulse 1.2s ease-in-out infinite; +} +@keyframes ot-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.45; } +} + +[data-module="overflow-tap"] .ot-dest { + color: var(--text); + font-size: min(clamp(0.7rem, 16cqh, 1.9rem), 8cqw); + font-weight: 600; + line-height: 1.2; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +[data-module="overflow-tap"] .ot-main[data-tone="muted"] .ot-dest { color: var(--muted); font-weight: 500; } + +[data-module="overflow-tap"] .ot-groups { + color: var(--muted); + font-size: clamp(0.5rem, 9cqh, 0.95rem); + line-height: 1.3; + flex-shrink: 0; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +[data-module="overflow-tap"] .ot-error { + color: var(--danger); + font-size: clamp(0.5rem, 9cqh, 0.95rem); + line-height: 1.3; + max-width: 100%; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} +[data-module="overflow-tap"] .ot-error.is-warn { color: var(--warn); } + +/* ── this slide ─────────────────────────────────────────────────── */ + +[data-module="overflow-tap"] .ot-slide { + flex-shrink: 0; + text-align: center; + font-size: clamp(0.55rem, 4.8cqmin, 0.95rem); + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + padding: 0.25em 0.6em; + border-radius: 999px; + background: color-mix(in srgb, var(--text) 7%, transparent); + align-self: center; + max-width: 100%; +} +[data-module="overflow-tap"] .ot-slide.is-muted { color: var(--muted); } +[data-module="overflow-tap"] .ot-slide.is-hit { + color: var(--accent-text); + background: color-mix(in srgb, var(--accent) 80%, transparent); + font-weight: 650; +} +[data-module="overflow-tap"] .ot-slide.is-warn { + color: var(--warn); + background: color-mix(in srgb, var(--warn) 16%, transparent); +} + +/* ── the trail ──────────────────────────────────────────────────── */ + +[data-module="overflow-tap"] .ot-history { + flex-shrink: 0; + list-style: none; + margin: 0; + padding: 0.3em 0 0; + border-top: 1px solid var(--border); + font-size: clamp(0.55rem, 4.6cqmin, 0.95rem); + display: flex; + flex-direction: column; + gap: 0.1em; +} + +[data-module="overflow-tap"] .ot-h-row { + display: grid; + grid-template-columns: auto minmax(0, 1fr) minmax(0, 1.4fr) auto; + align-items: baseline; + gap: 0 0.7em; + color: var(--text); +} +[data-module="overflow-tap"] .ot-h-row.is-failed .ot-h-kw, +[data-module="overflow-tap"] .ot-h-row.is-failed .ot-h-mark { color: var(--danger); } +[data-module="overflow-tap"] .ot-h-time { + color: var(--muted); + font-variant-numeric: tabular-nums; +} +[data-module="overflow-tap"] .ot-h-kw { font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +[data-module="overflow-tap"] .ot-h-dest { color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +[data-module="overflow-tap"] .ot-h-mark { color: var(--accent); font-weight: 700; } +[data-module="overflow-tap"] .ot-h-empty { color: var(--muted); text-align: center; } + +/* On a short tile the trail would crowd the keyword out: keep the + keyword, drop the trail. Inside the keyword block the same rule: when + it is short, the small lines go before the keyword does. */ +@container (max-height: 120px) { + [data-module="overflow-tap"] .ot-history { display: none; } +} +@container (max-height: 70px) { + [data-module="overflow-tap"] .ot-groups, + [data-module="overflow-tap"] .ot-error { display: none; } +} +@container (max-height: 46px) { + [data-module="overflow-tap"] .ot-dest, + [data-module="overflow-tap"] .ot-label { display: none; } +} + +[data-module="overflow-tap"] .ot-empty { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 6%; + color: var(--muted); + text-align: center; + font-size: clamp(0.65rem, 5.5cqmin, 1rem); +} diff --git a/tools/overflow-tap-mock.js b/tools/overflow-tap-mock.js new file mode 100644 index 0000000..fa1d54b --- /dev/null +++ b/tools/overflow-tap-mock.js @@ -0,0 +1,255 @@ +/** + * 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] + * [--login=:] [--expire=] + * [--fail-switch] [--slow] [--no-announcements] + * + * 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 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) => { + const a = args.find((x) => x.startsWith(`--${name}=`)); + return a ? a.slice(name.length + 3) : 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 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 ────────────────────────────────────────────────── */ + +const SLIDES = [ + { uuid: 'S1', text: 'Welcome to Waters Church', notes: '' }, + { uuid: 'S2', text: 'Give online', notes: 'give' }, + { uuid: 'S3', text: 'Amazing grace, how sweet the sound', notes: 'Chorus x2' }, + { uuid: 'S4', text: 'Fill out a Connect Card', notes: 'Next steps: connect' }, + { uuid: 'S5', text: 'A sermon on forgiveness', notes: 'forgiveness — do not switch on this' }, + { uuid: 'S6', text: 'Pray with us', notes: 'prayer' }, + { uuid: 'S7', text: 'Baptism Sunday', notes: 'baptism' }, + null, // nothing live + { uuid: 'S8', text: 'Give online (again)', notes: 'give' }, + { uuid: 'S9', text: 'See you next week', notes: '' }, +]; +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]; + 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, + }; +} + +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: !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${NO_ANN ? ', no announcement layer' : ''})`); +}); + +/* ── 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', 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() })); + +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; +} + +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; + 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()); + } + } + answer(404, { statusCode: 404, message: `Cannot ${req.method} ${p}` }); +}).listen(OF_PORT, '127.0.0.1', () => { + 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)` : ''}`); +});