Overflow Tap 1.0.0: switch Overflow Tap destinations from keywords in ProPresenter slide notes

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>
This commit is contained in:
Waters Church
2026-10-07 14:00:37 -04:00
co-authored by Claude Fable 5.1
parent c9e03398a6
commit 6b51044be6
10 changed files with 1692 additions and 2 deletions
+126
View File
@@ -0,0 +1,126 @@
'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 };