A new module that does what Overflow's "ProPresenter Integration" helper app
does, inside ProdDash: the live slide's notes are read from ProPresenter
(GET /v1/status/slide, twice a second) and when they carry one of the admin
page's keywords, the chosen Overflow Tap groups are pointed at that keyword's
destination. One tile: the keyword Tap is on (and since when), its
destination and the groups moved, a chip saying whether the slide on screen
carries a keyword, and the last four switches. A failed switch turns the tile
red with the reason, retries three times, and can flash an alert channel.
Admin config: ProPresenter endpoint + password; Overflow client ID + API key
(x-client-id / x-api-key, the public API's two headers); Tap groups to switch
(multiselect read from the account over /groups); keywords as text, one per
line (`give = Give Online`, optionally `@ Lobby, Kids Wing`); match mode
(whole word / anywhere / own line); alerts switch + channel.
The one unverified piece: Overflow's public API documents only reads for Tap
(OpenAPI checked 2026-10-07), while Overflow's helper uses the Dashboard's
private API. The switch is sent as PATCH /api/v3/tap/groups/{id} with the new
destinationId — the natural shape for this API — and every switch is confirmed
by reading the group back, so a refusal shows on the tile instead of passing
silently. overflow.js is the one place to change if Overflow names a
different route.
Also: tools/overflow-tap-mock.js (a ProPresenter whose slide notes carry
keywords on :1602 plus the Overflow Tap API on :24720, --read-only to fail
every switch), keywords.selftest.js (14 cases), README + CLAUDE.md entries.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
184 lines
9.6 KiB
JavaScript
184 lines
9.6 KiB
JavaScript
/**
|
|
* Mock ProPresenter slide status + mock Overflow Tap API, for developing the
|
|
* overflow-tap module without a live ProPresenter or an Overflow account.
|
|
*
|
|
* node tools/overflow-tap-mock.js [--pp=1602] [--overflow=24720] [--step=4000]
|
|
* [--key=<apiKey>] [--read-only] [--slow]
|
|
*
|
|
* ProPresenter side (127.0.0.1:1602 by default) — the one route the module
|
|
* uses, with a service that advances every --step ms:
|
|
* GET /version reachability probe
|
|
* GET /v1/status/slide { current: { uuid, text, notes }, next }, cycling
|
|
* through slides whose notes carry keywords
|
|
* ("give", "Next steps: connect", "prayer"), slides
|
|
* with other notes ("forgiveness" — must NOT fire
|
|
* "give" in whole-word mode), a slide whose keyword
|
|
* maps to nothing on the account, and a stretch with
|
|
* nothing live.
|
|
*
|
|
* Overflow side (127.0.0.1:24720 by default; run ProdDash with
|
|
* OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720) — the documented reads with
|
|
* paging, and the switch the module sends:
|
|
* GET /api/v3/tap/destinations four destinations
|
|
* GET /api/v3/tap/groups three groups, each pointing somewhere
|
|
* GET /api/v3/tap/groups/{id}
|
|
* PATCH /api/v3/tap/groups/{id} { destinationId } — moves the group.
|
|
* --read-only answers 404 instead, which
|
|
* is what Overflow's public API does
|
|
* today, to exercise the failure path.
|
|
* Every request must carry x-client-id and x-api-key (any non-empty values;
|
|
* --key=<apiKey> demands that exact key, answering 401 otherwise). --slow adds
|
|
* a two-second delay to every Overflow answer.
|
|
*
|
|
* Suggested admin Keywords for this mock:
|
|
* give = Give Online
|
|
* connect = Connect Card @ Lobby
|
|
* prayer = Prayer Request
|
|
* baptism = Baptism Signup
|
|
*
|
|
* Every incoming request is logged so we can confirm what the module does.
|
|
*/
|
|
'use strict';
|
|
|
|
const http = require('http');
|
|
|
|
const args = process.argv.slice(2);
|
|
const opt = (name, dflt) => {
|
|
const a = args.find((x) => x.startsWith(`--${name}=`));
|
|
return a ? a.slice(name.length + 3) : dflt;
|
|
};
|
|
const PP_PORT = Number.parseInt(opt('pp', '1602'), 10);
|
|
const OF_PORT = Number.parseInt(opt('overflow', '24720'), 10);
|
|
const STEP_MS = Number.parseInt(opt('step', '4000'), 10);
|
|
const KEY = opt('key', '');
|
|
const READ_ONLY = args.includes('--read-only');
|
|
const SLOW = args.includes('--slow');
|
|
|
|
/* ── ProPresenter ────────────────────────────────────────────────── */
|
|
|
|
const SLIDES = [
|
|
{ uuid: 'S1', text: 'Welcome to Waters Church', notes: '' },
|
|
{ uuid: 'S2', text: 'Give online', notes: 'give' },
|
|
{ uuid: 'S3', text: 'Amazing grace, how sweet the sound', notes: 'Chorus x2' },
|
|
{ uuid: 'S4', text: 'Fill out a Connect Card', notes: 'Next steps: connect' },
|
|
{ uuid: 'S5', text: 'A sermon on forgiveness', notes: 'forgiveness — do not switch on this' },
|
|
{ uuid: 'S6', text: 'Pray with us', notes: 'prayer' },
|
|
{ uuid: 'S7', text: 'Baptism Sunday', notes: 'baptism' },
|
|
null, // nothing live
|
|
{ uuid: 'S8', text: 'Give online (again)', notes: 'give' },
|
|
{ uuid: 'S9', text: 'See you next week', notes: '' },
|
|
];
|
|
let slideIdx = 0;
|
|
setInterval(() => { slideIdx = (slideIdx + 1) % SLIDES.length; }, STEP_MS).unref();
|
|
|
|
function slidePayload() {
|
|
const cur = SLIDES[slideIdx];
|
|
const nxt = SLIDES[(slideIdx + 1) % SLIDES.length];
|
|
return {
|
|
current: cur ? { uuid: cur.uuid, text: cur.text, notes: cur.notes } : null,
|
|
next: nxt ? { uuid: nxt.uuid, text: nxt.text, notes: nxt.notes } : null,
|
|
};
|
|
}
|
|
|
|
function sendJson(res, code, body, extra = {}) {
|
|
res.writeHead(code, { 'Content-Type': 'application/json', ...extra });
|
|
res.end(JSON.stringify(body));
|
|
}
|
|
|
|
http.createServer((req, res) => {
|
|
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
const p = url.pathname;
|
|
console.log(`[pp] ${req.method} ${p}`);
|
|
if (p === '/version') return sendJson(res, 200, { name: 'Overflow mock', platform: 'mac', host_description: 'ProPresenter 21 (mock)', api_version: 'v1' });
|
|
if (p === '/v1/status/slide') return sendJson(res, 200, slidePayload());
|
|
if (p === '/v1/status/layers') return sendJson(res, 200, { slide: Boolean(SLIDES[slideIdx]), media: false, announcements: false });
|
|
sendJson(res, 404, { error: 'not found' });
|
|
}).listen(PP_PORT, '127.0.0.1', () => {
|
|
console.log(`[pp] mock ProPresenter on http://127.0.0.1:${PP_PORT} (slide every ${STEP_MS} ms)`);
|
|
});
|
|
|
|
/* ── Overflow ────────────────────────────────────────────────────── */
|
|
|
|
const stamp = (d) => new Date(d).toISOString();
|
|
const DESTINATIONS = [
|
|
{ id: '6710f34fd5061afeec3eab01', name: 'Give Online', type: 'web', url: 'https://example.org/give', textNumber: null, textContent: null },
|
|
{ id: '6710f34fd5061afeec3eab02', name: 'Connect Card', type: 'web', url: 'https://example.org/connect', textNumber: null, textContent: null },
|
|
{ id: '6710f34fd5061afeec3eab03', name: 'Prayer Request', type: 'web', url: 'https://example.org/pray', textNumber: null, textContent: null },
|
|
{ id: '6710f34fd5061afeec3eab04', name: 'Events', type: 'sms', url: null, textNumber: '+15550100', textContent: 'EVENTS' },
|
|
].map((d) => ({ ...d, createdAt: stamp('2026-05-01T17:22:11Z'), updatedAt: stamp('2026-05-04T14:09:32Z'), archivedAt: null }));
|
|
|
|
const GROUPS = [
|
|
{ id: '6710f34fd5061afeec3eab11', name: 'Auditorium', destinationId: DESTINATIONS[3].id, deviceIds: ['6710f34fd5061afeec3eab21', '6710f34fd5061afeec3eab22'] },
|
|
{ id: '6710f34fd5061afeec3eab12', name: 'Lobby', destinationId: DESTINATIONS[3].id, deviceIds: ['6710f34fd5061afeec3eab23'] },
|
|
{ id: '6710f34fd5061afeec3eab13', name: 'Kids Wing', destinationId: DESTINATIONS[1].id, deviceIds: [] },
|
|
].map((g) => ({ ...g, createdAt: stamp('2026-05-01T17:22:11Z'), updatedAt: stamp('2026-05-04T14:09:32Z'), archivedAt: null }));
|
|
|
|
function destView(id) {
|
|
const d = DESTINATIONS.find((x) => x.id === id);
|
|
return d ? { id: d.id, name: d.name, type: d.type, url: d.url, textNumber: d.textNumber, textContent: d.textContent } : null;
|
|
}
|
|
function groupView(g) {
|
|
return { ...g, destination: destView(g.destinationId) };
|
|
}
|
|
function page(list, url) {
|
|
const limit = Math.min(100, Math.max(1, Number(url.searchParams.get('limit')) || 25));
|
|
const pg = Math.max(1, Number(url.searchParams.get('page')) || 1);
|
|
const start = (pg - 1) * limit;
|
|
return { data: list.slice(start, start + limit), totalCount: list.length };
|
|
}
|
|
|
|
let served = 0;
|
|
http.createServer((req, res) => {
|
|
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
const p = url.pathname;
|
|
served += 1;
|
|
const rl = { 'x-ratelimit-limit': '120', 'x-ratelimit-remaining': String(Math.max(0, 120 - (served % 120))), 'x-ratelimit-reset': '59' };
|
|
const answer = (code, body) => {
|
|
const go = () => sendJson(res, code, body, rl);
|
|
if (SLOW) setTimeout(go, 2000);
|
|
else go();
|
|
};
|
|
const cid = String(req.headers['x-client-id'] || '');
|
|
const key = String(req.headers['x-api-key'] || '');
|
|
console.log(`[overflow] ${req.method} ${p}${url.search}${cid ? '' : ' (no client id)'}${key ? '' : ' (no api key)'}`);
|
|
if (!cid || !key || (KEY && key !== KEY)) return answer(401, { statusCode: 401, message: 'Unauthorized' });
|
|
|
|
if (req.method === 'GET' && p === '/api/v3/tap/destinations') return answer(200, page(DESTINATIONS, url));
|
|
const dOne = /^\/api\/v3\/tap\/destinations\/([^/]+)$/.exec(p);
|
|
if (req.method === 'GET' && dOne) {
|
|
const d = DESTINATIONS.find((x) => x.id === dOne[1]);
|
|
return d ? answer(200, { data: d }) : answer(404, { statusCode: 404, message: 'Destination not found' });
|
|
}
|
|
if (req.method === 'GET' && p === '/api/v3/tap/groups') return answer(200, page(GROUPS.map(groupView), url));
|
|
const gOne = /^\/api\/v3\/tap\/groups\/([^/]+)$/.exec(p);
|
|
if (gOne) {
|
|
const g = GROUPS.find((x) => x.id === gOne[1]);
|
|
if (!g) return answer(404, { statusCode: 404, message: 'Group not found' });
|
|
if (req.method === 'GET') return answer(200, { data: groupView(g) });
|
|
if (req.method === 'PATCH' || req.method === 'PUT') {
|
|
if (READ_ONLY) return answer(404, { statusCode: 404, message: `Cannot ${req.method} /api/v3/tap/groups/${g.id}` });
|
|
let body = '';
|
|
req.on('data', (c) => (body += c));
|
|
req.on('end', () => {
|
|
let parsed = {};
|
|
try { parsed = JSON.parse(body || '{}'); } catch { return answer(400, { statusCode: 400, message: 'Invalid JSON' }); }
|
|
const dest = DESTINATIONS.find((x) => x.id === parsed.destinationId);
|
|
if (!dest) return answer(400, { statusCode: 400, message: 'destinationId must be an existing destination' });
|
|
g.destinationId = dest.id;
|
|
g.updatedAt = stamp(Date.now());
|
|
console.log(`[overflow] ${g.name} → ${dest.name}`);
|
|
answer(200, { data: groupView(g) });
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
if (req.method === 'GET' && p === '/api/v3/tap/devices') {
|
|
const devices = GROUPS.flatMap((g) => g.deviceIds.map((id) => ({ id, serialNumber: `TAP-${id.slice(-4)}`, groupId: g.id })));
|
|
return answer(200, page(devices, url));
|
|
}
|
|
if (req.method === 'GET' && p === '/api/v3/tap/events') return answer(200, { data: [], totalCount: 0 });
|
|
answer(404, { statusCode: 404, message: `Cannot ${req.method} ${p}` });
|
|
}).listen(OF_PORT, '127.0.0.1', () => {
|
|
console.log(`[overflow] mock Overflow API on http://127.0.0.1:${OF_PORT}${READ_ONLY ? ' (read-only: PATCH answers 404)' : ''}${KEY ? ' (api key required)' : ''}`);
|
|
});
|