Files
ProdDash/modules/overflow-tap/overflow.js
T
Waters ChurchandClaude Fable 5.1 6b51044be6 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>
2026-10-07 14:00:37 -04:00

127 lines
5.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 };