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>
127 lines
5.4 KiB
JavaScript
127 lines
5.4 KiB
JavaScript
'use strict';
|
||
|
||
/**
|
||
* Overflow API client — the Tap part of Overflow's public REST API.
|
||
*
|
||
* https://docs.overflow.co/api-reference (OpenAPI: server.stage.overflow.co/api/docs/openapi.json)
|
||
*
|
||
* Authentication is two headers, `x-client-id` and `x-api-key`, from the
|
||
* Overflow Dashboard's Developers › API Keys. 120 requests per minute per
|
||
* client; a 429 is answered when the window is spent.
|
||
*
|
||
* Reads used here are documented: GET /api/v3/tap/destinations,
|
||
* GET /api/v3/tap/groups and GET /api/v3/tap/groups/{id} (a group carries the
|
||
* `destinationId` it currently points at).
|
||
*
|
||
* THE SWITCH IS NOT (as of 2026-10-07). The public reference lists reads only
|
||
* for Tap; Overflow's own ProPresenter helper changes a group's destination
|
||
* through the Dashboard's private API after signing in with a Dashboard
|
||
* login. `setGroupDestination` below sends the natural REST shape for this
|
||
* API — PATCH the group with the new `destinationId`, as PATCH /donors/{id}
|
||
* updates a donor — and the server confirms every switch by reading the group
|
||
* back, so an answer that changes nothing is reported, not believed. If
|
||
* Overflow publishes (or confirms) a different route, this one function is
|
||
* the only place to change. tools/overflow-tap-mock.js serves the same shape.
|
||
*
|
||
* OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720 points the client at that mock.
|
||
*/
|
||
|
||
const DEFAULT_API_BASE = 'https://server.overflow.co';
|
||
const API_BASE = String(process.env.OVERFLOW_TAP_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, '');
|
||
const TIMEOUT_MS = 10000;
|
||
const PAGE_SIZE = 100;
|
||
const MAX_PAGES = 20;
|
||
const ID_RE = /^[0-9a-f]{24}$/i;
|
||
|
||
class OverflowError extends Error {
|
||
constructor(message, { status = 0, retryable = false } = {}) {
|
||
super(message);
|
||
this.name = 'OverflowError';
|
||
this.status = status;
|
||
this.retryable = retryable;
|
||
}
|
||
}
|
||
|
||
function looksLikeId(s) {
|
||
return ID_RE.test(String(s || '').trim());
|
||
}
|
||
|
||
function createOverflowClient({ clientId, apiKey, fetchImpl = globalThis.fetch }) {
|
||
const id = String(clientId || '').trim();
|
||
const key = String(apiKey || '').trim();
|
||
|
||
async function request(method, path, { query, body } = {}) {
|
||
if (!id || !key) throw new OverflowError('Overflow client ID and API key are not set', { status: 0 });
|
||
const url = new URL(API_BASE + path);
|
||
for (const [k, v] of Object.entries(query || {})) {
|
||
if (v === undefined || v === null || v === '') continue;
|
||
url.searchParams.set(k, String(v));
|
||
}
|
||
const headers = { Accept: 'application/json', 'x-client-id': id, 'x-api-key': key };
|
||
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
||
const ctl = new AbortController();
|
||
const timer = setTimeout(() => ctl.abort(), TIMEOUT_MS);
|
||
let res;
|
||
try {
|
||
res = await fetchImpl(url, {
|
||
method,
|
||
headers,
|
||
body: body === undefined ? undefined : JSON.stringify(body),
|
||
signal: ctl.signal,
|
||
});
|
||
} catch (err) {
|
||
const aborted = err && err.name === 'AbortError';
|
||
const cause = err && err.cause && (err.cause.code || err.cause.message);
|
||
throw new OverflowError(aborted ? `Overflow did not answer within ${TIMEOUT_MS / 1000} s` : `Overflow unreachable: ${err.message}${cause ? ` (${cause})` : ''}`, { retryable: true });
|
||
} finally {
|
||
clearTimeout(timer);
|
||
}
|
||
const text = await res.text();
|
||
let data = null;
|
||
if (text) {
|
||
try { data = JSON.parse(text); } catch { data = null; }
|
||
}
|
||
if (!res.ok) {
|
||
const detail = data && (data.message || data.error || data.detail);
|
||
const what = Array.isArray(detail) ? detail.join('; ') : (detail ? String(detail) : res.statusText);
|
||
let message;
|
||
if (res.status === 401 || res.status === 403) message = `Overflow refused the credentials (${res.status}${what ? `: ${what}` : ''})`;
|
||
else if (res.status === 404 || res.status === 405) message = `Overflow has no ${method} ${path} (${res.status})`;
|
||
else if (res.status === 429) message = 'Overflow is rate-limiting (120 requests a minute)';
|
||
else message = `Overflow answered ${res.status}${what ? `: ${what}` : ''}`;
|
||
throw new OverflowError(message, { status: res.status, retryable: res.status === 429 || res.status >= 500 });
|
||
}
|
||
return data;
|
||
}
|
||
|
||
/** Every page of a collection, newest-first as the API sorts it. */
|
||
async function listAll(path, query = {}) {
|
||
const all = [];
|
||
for (let page = 1; page <= MAX_PAGES; page += 1) {
|
||
const res = await request('GET', path, { query: { ...query, limit: PAGE_SIZE, page } });
|
||
const data = Array.isArray(res && res.data) ? res.data : [];
|
||
all.push(...data);
|
||
const total = Number(res && res.totalCount);
|
||
if (data.length < PAGE_SIZE || (Number.isFinite(total) && all.length >= total)) break;
|
||
}
|
||
return all;
|
||
}
|
||
|
||
return {
|
||
base: API_BASE,
|
||
configured: Boolean(id && key),
|
||
listDestinations: () => listAll('/api/v3/tap/destinations'),
|
||
listGroups: () => listAll('/api/v3/tap/groups'),
|
||
async getGroup(groupId) {
|
||
const res = await request('GET', `/api/v3/tap/groups/${encodeURIComponent(groupId)}`);
|
||
return res && res.data ? res.data : res;
|
||
},
|
||
/** See the note at the top of this file: the one unverified call. */
|
||
setGroupDestination(groupId, destinationId) {
|
||
return request('PATCH', `/api/v3/tap/groups/${encodeURIComponent(groupId)}`, { body: { destinationId } });
|
||
},
|
||
};
|
||
}
|
||
|
||
module.exports = { createOverflowClient, OverflowError, looksLikeId, API_BASE, DEFAULT_API_BASE };
|