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