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>
553 lines
22 KiB
JavaScript
553 lines
22 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* Overflow Tap — server entry.
|
|
*
|
|
* Does what Overflow's "ProPresenter Integration" helper app does, inside
|
|
* ProdDash: watches the live slide in ProPresenter, and when its notes carry
|
|
* one of the admin page's keywords, points the chosen Overflow Tap groups at
|
|
* that keyword's destination — so what people reach when they 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 /stream → SSE, `state` events on every change (heartbeated)
|
|
* POST /refresh → re-read Overflow's groups and destinations now
|
|
* GET /groups → optionsRoute for the admin page's Tap groups picker
|
|
* GET /destinations → the destinations, for a quick look while setting up
|
|
*
|
|
* ProPresenter is polled (GET /v1/status/slide, twice a second — one small
|
|
* request, nothing heavier) because its chunked update stream is the thing
|
|
* that goes quiet without saying so. Overflow is read on start, every few
|
|
* minutes after that, and on /refresh; every switch is confirmed by reading
|
|
* the group back. Which Overflow call does the switching, and why that call
|
|
* is the one unverified piece, is explained at the top of overflow.js.
|
|
*
|
|
* 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.
|
|
*/
|
|
|
|
const { createOverflowClient, OverflowError, looksLikeId } = require('./overflow');
|
|
const { parseKeywordLines, matchKeyword, cleanNotes, norm } = require('./keywords');
|
|
|
|
const POLL_MS = 500; // ProPresenter, while it answers
|
|
const RETRY_MS = 3000; // ProPresenter, while it does not
|
|
const PP_TIMEOUT_MS = 4000;
|
|
const HEARTBEAT_MS = 15000;
|
|
const DESTINATIONS_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 SWITCH_RETRY_MS = 10 * 1000;
|
|
const SWITCH_RETRIES = 3;
|
|
const HISTORY_MAX = 8; // the tile shows four; a few more for the hover
|
|
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. */
|
|
let current = null;
|
|
|
|
function json(res, code, body) {
|
|
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
res.end(JSON.stringify(body));
|
|
}
|
|
|
|
/** One line for a log or a tile; a fetch failure names its cause (ECONNREFUSED, ENOTFOUND…). */
|
|
function describe(err) {
|
|
if (err instanceof OverflowError) return err.message;
|
|
if (err && err.name === 'AbortError') return 'timed out';
|
|
const msg = err && err.message ? err.message : String(err);
|
|
const cause = err && err.cause && (err.cause.code || err.cause.message);
|
|
return cause && !msg.includes(String(cause)) ? `${msg} (${cause})` : msg;
|
|
}
|
|
|
|
module.exports = {
|
|
init({ config, log, shell }) {
|
|
/* ── config ────────────────────────────────────────────────────── */
|
|
const ep = config.propresenter && typeof config.propresenter === 'object' ? config.propresenter : {};
|
|
const host = String(ep.host || '').trim();
|
|
const port = Number(ep.port) || 1025;
|
|
const password = String(config.password || '');
|
|
const chosenGroups = (Array.isArray(config.groups) ? config.groups : []).map((g) => String(g || '').trim()).filter(Boolean);
|
|
const mappings = parseKeywordLines(config.keywords);
|
|
const mode = MATCH_MODES.has(config.matching) ? config.matching : 'word';
|
|
const alertEnabled = Boolean(config.alertEnabled);
|
|
const alertChannel = Math.min(6, Math.max(1, Math.round(Number(config.alertChannel) || 1)));
|
|
|
|
const overflow = createOverflowClient({ clientId: config.clientId, apiKey: config.apiKey });
|
|
|
|
/* ── state ─────────────────────────────────────────────────────── */
|
|
const streams = new Set();
|
|
const timers = new Set();
|
|
let stopped = false;
|
|
let lastFrame = '';
|
|
|
|
let destinations = []; // Overflow's, as listed
|
|
let groups = []; // Overflow's, as listed (each with its current destinationId)
|
|
let resolved = []; // the mappings, resolved against those two lists
|
|
let lastSlideKey = null; // change detection on the live slide
|
|
let wanted = null; // the resolved keyword Tap should be on
|
|
let inFlight = false;
|
|
let retriesLeft = 0;
|
|
let retryTimer = null;
|
|
let alertRaised = false;
|
|
|
|
const state = {
|
|
status: 'connecting',
|
|
message: '',
|
|
propresenter: { configured: Boolean(host), reachable: false, error: '', host, port },
|
|
overflow: { configured: overflow.configured, reachable: false, error: '', groups: 0, destinations: 0, loadedAt: 0 },
|
|
keywords: [],
|
|
slide: { live: false, keyword: '', unmapped: '', hasNotes: false },
|
|
current: null,
|
|
history: [],
|
|
updatedAt: 0,
|
|
};
|
|
|
|
/* ── timers that die with the handle ───────────────────────────── */
|
|
|
|
function later(fn, ms) {
|
|
const t = setTimeout(() => {
|
|
timers.delete(t);
|
|
if (!stopped) fn();
|
|
}, ms);
|
|
t.unref?.();
|
|
timers.add(t);
|
|
return t;
|
|
}
|
|
|
|
/* ── health, shared by the admin line and the tiles ────────────── */
|
|
|
|
function assess() {
|
|
const pp = state.propresenter;
|
|
const of = state.overflow;
|
|
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 (!mappings.length) return ['error', 'No keywords configured — add some in /admin'];
|
|
if (!of.reachable) return ['error', of.error || 'Reaching Overflow…'];
|
|
if (!pp.reachable) return ['error', pp.error ? `ProPresenter unreachable: ${pp.error}` : 'Reaching ProPresenter…'];
|
|
const bad = state.keywords.filter((k) => k.problem);
|
|
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) {
|
|
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}…`];
|
|
const extra = bad.length ? ` · ${bad.length} keyword${bad.length === 1 ? '' : 's'} without a destination: ${bad.map((k) => k.keyword).join(', ')}` : '';
|
|
const drift = state.current && state.current.drift ? ` · ${state.current.drift}` : '';
|
|
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}`];
|
|
}
|
|
|
|
function publish() {
|
|
if (stopped) return;
|
|
[state.status, state.message] = assess();
|
|
const { updatedAt, ...rest } = state;
|
|
let frame;
|
|
try {
|
|
frame = JSON.stringify(rest);
|
|
} catch {
|
|
return;
|
|
}
|
|
if (frame === lastFrame) return;
|
|
lastFrame = frame;
|
|
state.updatedAt = Date.now();
|
|
const payload = `event: state\ndata: ${JSON.stringify(state)}\n\n`;
|
|
for (const res of streams) {
|
|
try {
|
|
res.write(payload);
|
|
} catch {
|
|
streams.delete(res);
|
|
}
|
|
}
|
|
}
|
|
|
|
/* ── Overflow: destinations, groups, and the keywords resolved ─── */
|
|
|
|
function resolveKeywords() {
|
|
const dById = new Map(destinations.map((d) => [String(d.id), d]));
|
|
const dByName = new Map(destinations.map((d) => [norm(d.name), d]));
|
|
const gById = new Map(groups.map((g) => [String(g.id), g]));
|
|
const gByName = new Map(groups.map((g) => [norm(g.name), g]));
|
|
const defaults = chosenGroups.length
|
|
? 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);
|
|
out.destinationId = String(d.id);
|
|
let gs;
|
|
if (m.groups.length) {
|
|
gs = [];
|
|
const missing = [];
|
|
for (const name of m.groups) {
|
|
const g = (looksLikeId(name) && gById.get(name)) || gByName.get(norm(name));
|
|
if (g) gs.push(g);
|
|
else missing.push(name);
|
|
}
|
|
if (missing.length) {
|
|
out.problem = `No Tap group named ${missing.map((x) => `"${x}"`).join(', ')}`;
|
|
return out;
|
|
}
|
|
} else {
|
|
gs = defaults;
|
|
}
|
|
if (!gs.length) {
|
|
out.problem = groups.length
|
|
? 'None of the chosen Tap groups exist any more'
|
|
: 'No Tap groups on this Overflow account';
|
|
return out;
|
|
}
|
|
out.groups = gs.map((g) => ({ id: String(g.id), name: String(g.name || g.id) }));
|
|
return out;
|
|
});
|
|
state.keywords = resolved.map((r) => ({
|
|
keyword: r.keyword,
|
|
destination: r.destination,
|
|
groups: r.groups.map((g) => g.name),
|
|
problem: r.problem,
|
|
}));
|
|
}
|
|
|
|
/** Has anyone moved a group we switched? Noted on the live entry, never fought. */
|
|
function noteDrift() {
|
|
const cur = state.current;
|
|
if (!cur || !cur.ok || cur.pending) return;
|
|
const byId = new Map(groups.map((g) => [String(g.id), g]));
|
|
const moved = [];
|
|
for (const g of cur.groupIds || []) {
|
|
const now = byId.get(g);
|
|
if (!now) continue;
|
|
if (String(now.destinationId || '') !== cur.destination.id) {
|
|
moved.push(`${now.name} now points at ${now.destination && now.destination.name ? now.destination.name : 'another destination'}`);
|
|
}
|
|
}
|
|
cur.drift = moved.length ? `changed in Overflow: ${moved.join('; ')}` : '';
|
|
}
|
|
|
|
let destinationsTimer = null;
|
|
let groupsTimer = null;
|
|
let loading = null;
|
|
|
|
async function loadOverflow(what = 'all') {
|
|
if (stopped || !overflow.configured) return;
|
|
if (loading) return loading;
|
|
loading = (async () => {
|
|
try {
|
|
if (what === 'all' || what === 'destinations') destinations = await overflow.listDestinations();
|
|
groups = await overflow.listGroups();
|
|
state.overflow.reachable = true;
|
|
state.overflow.error = '';
|
|
state.overflow.groups = groups.length;
|
|
state.overflow.destinations = destinations.length;
|
|
state.overflow.loadedAt = Date.now();
|
|
resolveKeywords();
|
|
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) {
|
|
state.overflow.reachable = false;
|
|
state.overflow.error = describe(err);
|
|
log(`Overflow: ${state.overflow.error}`);
|
|
if (!resolved.length) resolveKeywords(); // so the keyword list at least shows the lines
|
|
}
|
|
publish();
|
|
})();
|
|
try {
|
|
await loading;
|
|
} finally {
|
|
loading = null;
|
|
}
|
|
scheduleOverflow();
|
|
}
|
|
|
|
function scheduleOverflow() {
|
|
if (stopped) return;
|
|
if (destinationsTimer) clearTimeout(destinationsTimer);
|
|
if (groupsTimer) clearTimeout(groupsTimer);
|
|
timers.delete(destinationsTimer);
|
|
timers.delete(groupsTimer);
|
|
const ok = state.overflow.reachable;
|
|
destinationsTimer = later(() => loadOverflow('all'), ok ? DESTINATIONS_REFRESH_MS : OVERFLOW_RETRY_MS);
|
|
groupsTimer = ok ? later(() => loadOverflow('groups'), GROUPS_REFRESH_MS) : null;
|
|
}
|
|
|
|
/* ── the switch ────────────────────────────────────────────────── */
|
|
|
|
function pushHistory(entry) {
|
|
const { pending, drift, ...rest } = entry;
|
|
state.history.unshift(rest);
|
|
if (state.history.length > HISTORY_MAX) state.history.length = HISTORY_MAX;
|
|
}
|
|
|
|
function raiseAlert(keyword) {
|
|
if (!alertEnabled || !shell || typeof shell.alert !== 'function') return;
|
|
alertRaised = true;
|
|
Promise.resolve(shell.alert({ channel: alertChannel, level: 'medium', text: `Tap not switched: ${keyword}`, duration: ALERT_SECONDS }))
|
|
.catch((err) => log(`alert refused: ${describe(err)}`));
|
|
}
|
|
|
|
function clearAlert() {
|
|
if (!alertRaised || !shell || typeof shell.clearAlert !== 'function') return;
|
|
alertRaised = false;
|
|
Promise.resolve(shell.clearAlert(alertChannel)).catch(() => {});
|
|
}
|
|
|
|
function requestSwitch(target) {
|
|
wanted = target;
|
|
retriesLeft = SWITCH_RETRIES;
|
|
if (retryTimer) {
|
|
clearTimeout(retryTimer);
|
|
timers.delete(retryTimer);
|
|
retryTimer = null;
|
|
}
|
|
runSwitch();
|
|
}
|
|
|
|
async function runSwitch() {
|
|
if (stopped || inFlight || !wanted) return;
|
|
const target = wanted;
|
|
const cur = state.current;
|
|
const sameTarget = cur && cur.keyword === target.keyword && cur.destination.id === target.destinationId;
|
|
if (sameTarget && cur.ok) return; // already there
|
|
inFlight = true;
|
|
let entry;
|
|
if (sameTarget) {
|
|
entry = cur; // another go at the same switch
|
|
} else {
|
|
if (cur) pushHistory(cur);
|
|
entry = {
|
|
keyword: target.keyword,
|
|
destination: { id: target.destinationId, name: target.destination },
|
|
groups: target.groups.map((g) => g.name),
|
|
groupIds: target.groups.map((g) => g.id),
|
|
at: Date.now(),
|
|
ok: false,
|
|
pending: true,
|
|
error: '',
|
|
attempts: 0,
|
|
drift: '',
|
|
};
|
|
state.current = entry;
|
|
}
|
|
entry.pending = true;
|
|
entry.attempts += 1;
|
|
publish();
|
|
try {
|
|
for (const g of target.groups) {
|
|
if (stopped) return;
|
|
await overflow.setGroupDestination(g.id, target.destinationId);
|
|
const check = await overflow.getGroup(g.id);
|
|
const nowAt = check ? String(check.destinationId || '') : '';
|
|
if (check && nowAt !== target.destinationId) {
|
|
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.error = '';
|
|
entry.at = Date.now();
|
|
entry.drift = '';
|
|
clearAlert();
|
|
log(`Tap → ${target.destination} (${target.keyword}) on ${target.groups.map((g) => g.name).join(', ')}`);
|
|
} catch (err) {
|
|
if (stopped) return;
|
|
entry.ok = false;
|
|
entry.error = describe(err);
|
|
log(`Tap not switched to ${target.destination} (${target.keyword}): ${entry.error}`);
|
|
if (retriesLeft > 0) {
|
|
retriesLeft -= 1;
|
|
retryTimer = later(() => {
|
|
retryTimer = null;
|
|
runSwitch();
|
|
}, SWITCH_RETRY_MS);
|
|
}
|
|
raiseAlert(target.keyword);
|
|
} finally {
|
|
entry.pending = false;
|
|
inFlight = false;
|
|
publish();
|
|
if (!stopped && wanted !== target) runSwitch(); // a newer keyword arrived meanwhile
|
|
}
|
|
}
|
|
|
|
/* ── ProPresenter: the live slide ──────────────────────────────── */
|
|
|
|
function ppUrl(target) {
|
|
const u = new URL(`http://${host}:${port}${target}`);
|
|
if (password) u.searchParams.set('password', password);
|
|
return u;
|
|
}
|
|
|
|
async function ppJson(target) {
|
|
const ctl = new AbortController();
|
|
const timer = setTimeout(() => ctl.abort(), PP_TIMEOUT_MS);
|
|
try {
|
|
const res = await fetch(ppUrl(target), { headers: { Accept: 'application/json' }, signal: ctl.signal });
|
|
if (!res.ok) throw new Error(`ProPresenter ${res.status} ${res.statusText}`);
|
|
const text = await res.text();
|
|
return text ? JSON.parse(text) : null;
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
let lastSlide = null;
|
|
|
|
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() {
|
|
if (stopped) return;
|
|
let ok = false;
|
|
try {
|
|
const data = await ppJson('/v1/status/slide');
|
|
const cur = data && data.current && typeof data.current === 'object' ? data.current : null;
|
|
state.propresenter.reachable = true;
|
|
state.propresenter.error = '';
|
|
ok = true;
|
|
const key = cur ? `${cur.uuid || ''}\u0000${cur.text || ''}\u0000${cur.notes || ''}` : '';
|
|
if (key !== lastSlideKey) {
|
|
lastSlideKey = key;
|
|
lastSlide = cur;
|
|
reconsiderSlide();
|
|
}
|
|
} catch (err) {
|
|
state.propresenter.reachable = false;
|
|
state.propresenter.error = err && err.name === 'AbortError' ? 'timed out' : describe(err);
|
|
}
|
|
publish();
|
|
later(pollSlide, ok ? POLL_MS : RETRY_MS);
|
|
}
|
|
|
|
/* ── go ────────────────────────────────────────────────────────── */
|
|
|
|
resolveKeywords();
|
|
if (host) {
|
|
pollSlide();
|
|
log(`following ProPresenter at ${host}:${port}`);
|
|
} else {
|
|
log('no ProPresenter host configured — set one in /admin');
|
|
}
|
|
if (overflow.configured) {
|
|
loadOverflow('all');
|
|
log(`Overflow at ${overflow.base}`);
|
|
} else {
|
|
log('Overflow client ID / API key not set — add them in /admin');
|
|
}
|
|
publish();
|
|
|
|
const heartbeat = setInterval(() => {
|
|
for (const res of streams) {
|
|
try {
|
|
res.write(': ping\n\n');
|
|
} catch {
|
|
streams.delete(res);
|
|
}
|
|
}
|
|
}, HEARTBEAT_MS);
|
|
heartbeat.unref?.();
|
|
|
|
const handle = {
|
|
streams,
|
|
getState: () => state,
|
|
refresh: () => loadOverflow('all'),
|
|
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;
|
|
|
|
return {
|
|
stop() {
|
|
stopped = true;
|
|
clearInterval(heartbeat);
|
|
for (const t of timers) clearTimeout(t);
|
|
timers.clear();
|
|
clearAlert();
|
|
for (const res of streams) {
|
|
try { res.end(); } catch { /* already gone */ }
|
|
}
|
|
streams.clear();
|
|
if (current === handle) current = null;
|
|
},
|
|
health() {
|
|
const [status, message] = assess();
|
|
return { status, message };
|
|
},
|
|
};
|
|
},
|
|
|
|
routes() {
|
|
return {
|
|
'GET /state': (req, res) => {
|
|
if (!current) return json(res, 503, { error: 'Overflow Tap is not running.' });
|
|
json(res, 200, { state: current.getState() });
|
|
},
|
|
|
|
'GET /stream': (req, res) => {
|
|
res.writeHead(200, {
|
|
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
'Cache-Control': 'no-cache, no-transform',
|
|
Connection: 'keep-alive',
|
|
'X-Accel-Buffering': 'no',
|
|
});
|
|
res.write('retry: 3000\n\n');
|
|
if (!current) return void res.end();
|
|
res.write(`event: state\ndata: ${JSON.stringify(current.getState())}\n\n`);
|
|
try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
|
|
current.streams.add(res);
|
|
req.on('close', () => current && current.streams.delete(res));
|
|
},
|
|
|
|
'POST /refresh': async (req, res) => {
|
|
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.' });
|
|
await current.refresh();
|
|
const st = current.getState();
|
|
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 });
|
|
},
|
|
|
|
'GET /groups': async (req, res) => {
|
|
if (!current) return json(res, 200, { options: [] });
|
|
if (current.configured && !current.groupOptions().length) await current.refresh();
|
|
json(res, 200, { options: current.groupOptions() });
|
|
},
|
|
|
|
'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() });
|
|
},
|
|
};
|
|
},
|
|
};
|