A new send-message module owns everything a dashboard sends: the PIN
(switchable off in admin), the unlock tokens, the Send message and Quick
replies tiles, the quick replies and quick emojis. It opens no upstream
connection of its own — it finds every module declaring
provides: ["messaging"] through the shell's module list and hands each
message to that module's POST /messaging/send over loopback, so Slack keeps
its one Slack connection and ProdCom its one ProdCom connection. Providers
check the browser's unlock token with GET /verify over loopback before
touching their upstream, so no browser on the network can post as the
location without having entered the PIN here.
Slack 1.3.0 is view-only plus reactions: the Sending group, PIN, send tiles
and quick replies are gone; it serves the messaging descriptor (channels as
recipients) and its reactions ride on the Send Message unlock. A layout's
old Send message / Quick replies tiles draw nothing until removed.
ProdCom 1.4.0 serves the descriptor as identities — only channels with
typingEnabled, since typed text goes out *as* a channel — and posts with
POST /api/v1/channels/{id}/send. Its tile is a click-to-target like Slack's
(accent stripe along the bottom), and typed entries carry a keyboard mark.
The tiles: click any Slack or ProdCom transcript to point sends there; a
Send as menu (only for platforms with identities, hidden otherwise) picks
the ProdCom channel to speak as; the choice is saved with the tile so named
layouts remember it. Wording follows the role — "Send message to #ops…" /
"Send message to ProdCom as FOH…". The PIN pad's dots ride on a real
numeric input so a phone's keyboard appears. Outcome is an icon beside
Send (check / blocked, five seconds, sentence as tooltip). A pill on the
box's bottom edge trades box height for rows of quick-emoji keys, a row
at a time; the single row scrolls sideways on a narrow tile with Send
pinned in place.
The module guide documents the provider contract and the page bus
(window.proddashMessaging + proddash:messaging:* events) the tiles share;
the ProdCom mock gained the send route.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
533 lines
22 KiB
JavaScript
533 lines
22 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* ProdCom Transcript — server part.
|
|
*
|
|
* One connection to ProdCom for the whole server, however many dashboards
|
|
* show the transcript. The module holds the channel list, the groups and
|
|
* the last entries, follows ProdCom's SSE stream, and hands every browser a
|
|
* snapshot on connect plus each change as it happens. (It used to proxy each
|
|
* browser's stream to ProdCom one to one — N screens meant N upstream
|
|
* connections; ProdCom now sees one.)
|
|
*
|
|
* Routes (under /api/modules/prodcom-transcript):
|
|
* GET /state → { status, channels, groups, entries } (entries by date)
|
|
* GET /stream → SSE: `state` (the snapshot, first), then `entry` for each
|
|
* changed entry, `clear`, and `status` when the link changes
|
|
* GET /messaging → what the Send Message module may send as: every
|
|
* channel that accepts typed text (typingEnabled), as
|
|
* an identity — typed text goes out *as* that channel
|
|
* (the module guide's contract)
|
|
* POST /messaging/send → { destination, text } with the Send Message unlock
|
|
* as a Bearer token — checked with that module over
|
|
* loopback, then POST /api/v1/channels/{id}/send, so a
|
|
* dashboard's typed text goes out on a ProdCom channel
|
|
* over this one connection. This module has no PIN and
|
|
* no send tile of its own.
|
|
*
|
|
* ProdCom's API, as the reference listener speaks it:
|
|
* GET /api/v1/channels, /api/v1/groups, /api/v1/transcript?limit=N and the
|
|
* SSE /api/v1/transcript/stream, whose frames carry an entry, or a
|
|
* { type, entry | data } wrapper, in a few historical shapes (see
|
|
* extractEntries). POST /api/v1/channels/{id}/send with { text } puts typed
|
|
* text on a channel (it comes back on the stream with source "typed"). A
|
|
* bearer API key is sent when one is configured.
|
|
*/
|
|
|
|
const http = require('http');
|
|
|
|
/** The module that owns the PIN and the unlock tokens — see verifyUnlock(). */
|
|
const SENDER_ID = 'send-message';
|
|
const MAX_TEXT_CHARS = 2000;
|
|
const BODY_LIMIT_BYTES = 64 * 1024;
|
|
|
|
const HISTORY_LIMIT = 100; // entries asked for on (re)connect
|
|
const ENTRIES_MAX = 300; // entries kept in memory
|
|
const RETRY_MIN_MS = 2000;
|
|
const RETRY_MAX_MS = 15000;
|
|
const REQUEST_TIMEOUT_MS = 8000;
|
|
const REFRESH_LISTS_MS = 5 * 60 * 1000; // channels and groups re-read
|
|
const HEARTBEAT_MS = 15000;
|
|
|
|
/** The ProdCom base URL from the endpoint config. A legacy prodcomUrl (saved
|
|
before the endpoint field existed) can only still be present until the
|
|
admin form is re-saved, so when it exists it wins over the endpoint's
|
|
schema default. */
|
|
function resolveTarget(config) {
|
|
if (config.prodcomUrl) return new URL(config.prodcomUrl);
|
|
const ep = config.prodcom;
|
|
if (ep && ep.host && Number(ep.port)) return new URL(`http://${ep.host}:${Number(ep.port)}`);
|
|
return null;
|
|
}
|
|
|
|
/** Entries inside whatever ProdCom sent: a bare entry, or one wrapped in data / payload / entry / entries / transcript. */
|
|
function extractEntries(obj) {
|
|
if (!obj || typeof obj !== 'object') return [];
|
|
if (Array.isArray(obj)) return obj.flatMap(extractEntries);
|
|
if (typeof obj.text === 'string' && (obj.id || obj.channelId)) return [obj];
|
|
for (const key of ['data', 'payload', 'entry', 'entries', 'transcript']) {
|
|
if (obj[key] && typeof obj[key] === 'object') {
|
|
const found = extractEntries(obj[key]);
|
|
if (found.length) return found;
|
|
}
|
|
}
|
|
return [];
|
|
}
|
|
|
|
/** One entry as the tiles read it; an entry without an id is keyed by channel and time. */
|
|
function normalizeEntry(e) {
|
|
const id = e.id !== undefined && e.id !== null && e.id !== '' ? String(e.id) : `${e.channelId || ''}|${e.date || ''}`;
|
|
return {
|
|
id,
|
|
channelId: e.channelId === undefined || e.channelId === null ? '' : String(e.channelId),
|
|
channelName: e.channelName ? String(e.channelName) : '',
|
|
text: String(e.text ?? ''),
|
|
date: e.date ? String(e.date) : new Date().toISOString(),
|
|
inProgress: Boolean(e.inProgress),
|
|
// audio (speech), typed (a dashboard or ProdCom's own box), automation
|
|
source: e.source === 'typed' || e.source === 'automation' ? String(e.source) : 'audio',
|
|
};
|
|
}
|
|
|
|
/* ── small helpers ──────────────────────────────────────────────────── */
|
|
|
|
function sendJson(res, code, body) {
|
|
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
res.end(JSON.stringify(body));
|
|
}
|
|
|
|
function readJson(req) {
|
|
return new Promise((resolve, reject) => {
|
|
let data = '';
|
|
let size = 0;
|
|
req.on('data', (chunk) => {
|
|
size += chunk.length;
|
|
if (size > BODY_LIMIT_BYTES) {
|
|
reject(new Error('Request body too large.'));
|
|
req.destroy();
|
|
return;
|
|
}
|
|
data += chunk;
|
|
});
|
|
req.on('end', () => {
|
|
if (!data.trim()) return resolve({});
|
|
try {
|
|
const body = JSON.parse(data);
|
|
resolve(body && typeof body === 'object' ? body : {});
|
|
} catch {
|
|
reject(new Error('Malformed JSON.'));
|
|
}
|
|
});
|
|
req.on('error', reject);
|
|
});
|
|
}
|
|
|
|
/** Route handlers are async — the shell only catches synchronous throws. */
|
|
function guard(fn) {
|
|
return (req, res) => {
|
|
Promise.resolve()
|
|
.then(() => fn(req, res))
|
|
.catch((err) => {
|
|
if (!res.headersSent) sendJson(res, 500, { error: err?.message || String(err) });
|
|
else try { res.end(); } catch { /* gone */ }
|
|
});
|
|
};
|
|
}
|
|
|
|
function bearer(req) {
|
|
const m = /^Bearer\s+([A-Za-z0-9]+)\s*$/.exec(String(req.headers.authorization || ''));
|
|
return m ? m[1] : '';
|
|
}
|
|
|
|
/**
|
|
* Is the unlock token on this request good? Only the Send Message module
|
|
* knows — it owns the PIN — so ask it over loopback. Answers { ok: true,
|
|
* expiresAt } or { ok: false, status, error, code } ready to send back.
|
|
*/
|
|
async function verifyUnlock(req, shellPort) {
|
|
const token = bearer(req);
|
|
if (!token) return { ok: false, status: 401, error: 'Locked — enter the PIN', code: 'locked' };
|
|
if (!shellPort) return { ok: false, status: 503, error: 'This ProdDash cannot verify a PIN (needs 1.4.0 or newer)', code: 'no_shell_port' };
|
|
let res;
|
|
try {
|
|
res = await fetch(`http://127.0.0.1:${shellPort}/api/modules/${SENDER_ID}/verify`, {
|
|
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
|
|
signal: AbortSignal.timeout(5000),
|
|
});
|
|
} catch (err) {
|
|
return { ok: false, status: 502, error: `Cannot reach the Send Message module (${err.cause?.code || err.message})`, code: 'sender_unreachable' };
|
|
}
|
|
if (res.status === 404) return { ok: false, status: 503, error: 'Install the Send Message module to send from a dashboard', code: 'no_sender' };
|
|
let body = {};
|
|
try { body = await res.json(); } catch { /* a non-JSON refusal */ }
|
|
if (res.status === 401 || body?.ok === false) return { ok: false, status: 401, error: body?.error || 'Locked — enter the PIN', code: body?.code || 'locked' };
|
|
if (!res.ok) return { ok: false, status: 502, error: `Send Message module answered HTTP ${res.status}`, code: 'sender_error' };
|
|
return { ok: true, expiresAt: Number(body?.expiresAt) || 0 };
|
|
}
|
|
|
|
let current = null; // the running instance, for routes()
|
|
|
|
module.exports = {
|
|
init({ config, log, shell }) {
|
|
const shellPort = Number(shell && shell.port) || 0;
|
|
const target = resolveTarget(config);
|
|
const apiKey = String(config.apiKey || '');
|
|
const channels = [];
|
|
const groups = [];
|
|
const entries = new Map(); // id → entry, in arrival order
|
|
const streams = new Set(); // browsers' SSE responses
|
|
let status = { state: 'connecting', message: target ? 'Connecting to ProdCom…' : 'No ProdCom server configured' };
|
|
let stopped = false;
|
|
let upstream = null;
|
|
let generation = 0; // callbacks from a superseded connection are ignored
|
|
let retryTimer = null;
|
|
let retryMs = RETRY_MIN_MS;
|
|
|
|
const authHeaders = () => (apiKey ? { authorization: `Bearer ${apiKey}` } : {});
|
|
|
|
async function getJson(pathname) {
|
|
const res = await fetch(new URL(pathname, target), {
|
|
headers: { accept: 'application/json', ...authHeaders() },
|
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
});
|
|
if (!res.ok) throw new Error(`HTTP ${res.status} for ${pathname}`);
|
|
return res.json();
|
|
}
|
|
|
|
function sortedEntries() {
|
|
return [...entries.values()].sort((a, b) => new Date(a.date) - new Date(b.date));
|
|
}
|
|
|
|
function snapshot() {
|
|
return { status, channels, groups, entries: sortedEntries() };
|
|
}
|
|
|
|
function broadcast(event, data) {
|
|
if (!streams.size) return;
|
|
const frame = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
for (const res of streams) {
|
|
try {
|
|
res.write(frame);
|
|
} catch {
|
|
streams.delete(res);
|
|
}
|
|
}
|
|
}
|
|
|
|
function setStatus(state, message) {
|
|
if (status.state === state && status.message === message) return;
|
|
status = { state, message };
|
|
broadcast('status', status);
|
|
}
|
|
|
|
function trim() {
|
|
while (entries.size > ENTRIES_MAX) entries.delete(entries.keys().next().value);
|
|
}
|
|
|
|
/** A changed entry: kept, and told to every browser. */
|
|
function upsert(raw) {
|
|
const e = normalizeEntry(raw);
|
|
const prev = entries.get(e.id);
|
|
if (prev && prev.text === e.text && prev.inProgress === e.inProgress && prev.channelId === e.channelId) return;
|
|
entries.set(e.id, e);
|
|
trim();
|
|
broadcast('entry', e);
|
|
}
|
|
|
|
function clearAll() {
|
|
entries.clear();
|
|
broadcast('clear', {});
|
|
}
|
|
|
|
async function loadLists() {
|
|
const [ch, gr] = await Promise.all([
|
|
getJson('/api/v1/channels'),
|
|
getJson('/api/v1/groups').catch(() => ({ data: [] })), // groups are optional in ProdCom
|
|
]);
|
|
channels.splice(0, channels.length, ...(ch.data || []).map((c) => ({
|
|
id: String(c.id),
|
|
name: String(c.name || c.id),
|
|
color: c.color || '#8b98a5',
|
|
// whether ProdCom lets text be typed onto this channel (an older
|
|
// ProdCom that doesn't say is taken at its word when asked)
|
|
typingEnabled: c.typingEnabled !== false,
|
|
})));
|
|
groups.splice(0, groups.length, ...(gr.data || []).map((g) => ({ id: String(g.id), name: String(g.name || g.id), channelIds: Array.isArray(g.channelIds) ? g.channelIds.map(String) : [] })));
|
|
}
|
|
|
|
async function loadHistory() {
|
|
const body = await getJson(`/api/v1/transcript?limit=${HISTORY_LIMIT}`);
|
|
for (const e of extractEntries(body).map(normalizeEntry)) entries.set(e.id, e);
|
|
trim();
|
|
}
|
|
|
|
function handleFrame(block) {
|
|
const data = [];
|
|
for (const line of block.split('\n')) {
|
|
if (line.startsWith('data:')) data.push(line.slice(5).replace(/^ /, ''));
|
|
}
|
|
if (!data.length) return;
|
|
let obj;
|
|
try {
|
|
obj = JSON.parse(data.join('\n'));
|
|
} catch {
|
|
return;
|
|
}
|
|
const kind = String(obj?.type || obj?.event || '').toLowerCase();
|
|
if (kind.includes('clear')) return clearAll();
|
|
extractEntries(obj).forEach(upsert);
|
|
}
|
|
|
|
function scheduleRetry() {
|
|
if (stopped) return;
|
|
clearTimeout(retryTimer);
|
|
retryTimer = setTimeout(connect, retryMs);
|
|
retryMs = Math.min(RETRY_MAX_MS, retryMs * 2);
|
|
}
|
|
|
|
function fail(gen, why) {
|
|
if (stopped || gen !== generation) return;
|
|
generation += 1; // whatever else this connection reports is stale now
|
|
try { upstream?.destroy(); } catch { /* already gone */ }
|
|
upstream = null;
|
|
setStatus('error', `ProdCom unreachable (${why}) — retrying`);
|
|
log(`ProdCom ${target.origin}: ${why} — retrying in ${retryMs / 1000} s`);
|
|
scheduleRetry();
|
|
}
|
|
|
|
/** Lists and history first (a fresh snapshot for the tiles), then the live stream. */
|
|
async function connect() {
|
|
if (stopped || !target) return;
|
|
const gen = ++generation;
|
|
try {
|
|
await loadLists();
|
|
await loadHistory();
|
|
} catch (err) {
|
|
if (gen !== generation || stopped) return;
|
|
return fail(gen, err.cause?.code || err.code || err.message);
|
|
}
|
|
if (gen !== generation || stopped) return;
|
|
broadcast('state', snapshot());
|
|
const url = new URL('/api/v1/transcript/stream', target);
|
|
const req = http.request({
|
|
hostname: url.hostname,
|
|
port: url.port || 80,
|
|
path: url.pathname,
|
|
method: 'GET',
|
|
headers: { accept: 'text/event-stream', ...authHeaders() },
|
|
}, (res) => {
|
|
if (gen !== generation) return void res.destroy();
|
|
if (res.statusCode !== 200) {
|
|
res.resume();
|
|
return fail(gen, `HTTP ${res.statusCode} from the transcript stream`);
|
|
}
|
|
retryMs = RETRY_MIN_MS;
|
|
setStatus('ok', 'Live');
|
|
log(`following ${target.origin} — ${channels.length} channels, ${entries.size} entries`);
|
|
let buf = '';
|
|
res.setEncoding('utf8');
|
|
res.on('data', (chunk) => {
|
|
if (gen !== generation) return;
|
|
buf += chunk;
|
|
let i;
|
|
while ((i = buf.indexOf('\n\n')) >= 0) {
|
|
handleFrame(buf.slice(0, i));
|
|
buf = buf.slice(i + 2);
|
|
}
|
|
if (buf.length > 1e6) buf = ''; // a frame that never ends is dropped
|
|
});
|
|
res.on('end', () => fail(gen, 'stream ended'));
|
|
res.on('error', (err) => fail(gen, err.code || err.message));
|
|
});
|
|
req.on('error', (err) => fail(gen, err.code || err.message));
|
|
req.end();
|
|
upstream = req;
|
|
}
|
|
|
|
const listsTimer = setInterval(() => {
|
|
if (stopped || status.state !== 'ok') return;
|
|
loadLists().then(() => broadcast('state', snapshot())).catch(() => { /* the next reconnect re-reads them */ });
|
|
}, REFRESH_LISTS_MS);
|
|
listsTimer.unref?.();
|
|
const heartbeat = setInterval(() => {
|
|
for (const res of streams) {
|
|
try {
|
|
res.write(': ping\n\n');
|
|
} catch {
|
|
streams.delete(res);
|
|
}
|
|
}
|
|
}, HEARTBEAT_MS);
|
|
heartbeat.unref?.();
|
|
|
|
if (target) {
|
|
log(`one connection to ProdCom at ${target.origin}, shared by every dashboard`);
|
|
connect();
|
|
} else {
|
|
log('no ProdCom server configured — set the endpoint in /admin');
|
|
}
|
|
|
|
/** A channel by id, or by name (case-insensitive) — ProdCom accepts both, the tiles use ids. */
|
|
function channelByRef(ref) {
|
|
const s = String(ref ?? '').trim();
|
|
if (!s) return null;
|
|
return channels.find((c) => c.id === s) || channels.find((c) => c.name.toLowerCase() === s.toLowerCase()) || null;
|
|
}
|
|
|
|
/**
|
|
* What the Send Message module may send as: the channels that accept
|
|
* typed text. A ProdCom channel is an identity, not a recipient — text
|
|
* typed onto it goes out *as* that channel — so the destinations are
|
|
* declared as such, and a channel with typing off is left out.
|
|
*/
|
|
function messagingDescriptor() {
|
|
const h = current ? current.health() : { status: 'connecting', message: 'Restarting…' };
|
|
return {
|
|
label: 'ProdCom',
|
|
identity: '',
|
|
destinationRole: 'identity',
|
|
status: { state: h.status, message: h.message },
|
|
destinations: channels.filter((c) => c.typingEnabled).map((c) => ({ id: c.id, label: c.name, color: c.color || '' })),
|
|
emptyMessage: channels.length
|
|
? 'No ProdCom channel accepts typed text — turn on typing for one in ProdCom'
|
|
: 'No ProdCom channels yet',
|
|
maxChars: MAX_TEXT_CHARS,
|
|
};
|
|
}
|
|
|
|
/** Typed text onto a channel: the one ProdCom connection does the talking. */
|
|
async function sendText(ch, text) {
|
|
if (!target) return { status: 502, error: 'No ProdCom server configured — set the endpoint in Admin → ProdCom', code: 'no_target' };
|
|
let res;
|
|
try {
|
|
res = await fetch(new URL(`/api/v1/channels/${encodeURIComponent(ch.id)}/send`, target), {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json; charset=utf-8', accept: 'application/json', ...authHeaders() },
|
|
body: JSON.stringify({ text }),
|
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
});
|
|
} catch (err) {
|
|
return { status: 502, error: `ProdCom unreachable (${err.cause?.code || err.code || err.message}) — not sent`, code: 'network' };
|
|
}
|
|
if (res.ok) {
|
|
res.body?.cancel?.().catch?.(() => {});
|
|
return { status: 200 };
|
|
}
|
|
let detail = '';
|
|
let code = '';
|
|
try {
|
|
const body = await res.json();
|
|
detail = String(body?.error?.message || '');
|
|
code = String(body?.error?.code || '');
|
|
} catch { /* no JSON */ }
|
|
const tail = detail ? ` — ${detail}` : '';
|
|
if (res.status === 401) return { status: 502, error: `ProdCom rejected the API key (${code || 'UNAUTHORIZED'}) — check Admin → ProdCom`, code: 'unauthorized' };
|
|
if (res.status === 404) return { status: 404, error: `ProdCom has no channel "${ch.name}" any more (${code || 'NOT_FOUND'}) — pick another in Send as`, code: 'not_found' };
|
|
if (res.status === 429) return { status: 429, error: `ProdCom is rate-limiting — try again in a moment${tail}`, code: 'ratelimited' };
|
|
if (res.status === 400 || res.status === 422) return { status: 400, error: `ProdCom refused the message (${code || res.status})${tail}`, code: 'refused' };
|
|
return { status: 502, error: `ProdCom answered HTTP ${res.status}${tail}`, code: code || `http_${res.status}` };
|
|
}
|
|
|
|
current = {
|
|
snapshot,
|
|
streams,
|
|
shellPort,
|
|
channelByRef,
|
|
messagingDescriptor,
|
|
sendText,
|
|
log,
|
|
health() {
|
|
if (status.state === 'ok') return { status: 'ok', message: `Live — ${channels.length} channel${channels.length === 1 ? '' : 's'}, ${entries.size} entries` };
|
|
// Nothing to connect to is a setting that is missing, not a link that
|
|
// is coming up — it reads red, like the other modules say it.
|
|
if (!target) return { status: 'error', message: status.message };
|
|
return { status: status.state === 'error' ? 'error' : 'connecting', message: status.message };
|
|
},
|
|
};
|
|
|
|
return {
|
|
stop() {
|
|
stopped = true;
|
|
generation += 1;
|
|
clearTimeout(retryTimer);
|
|
clearInterval(listsTimer);
|
|
clearInterval(heartbeat);
|
|
try { upstream?.destroy(); } catch { /* already gone */ }
|
|
for (const res of streams) {
|
|
try { res.end(); } catch { /* gone */ }
|
|
}
|
|
streams.clear();
|
|
if (current?.streams === streams) current = null;
|
|
},
|
|
health: () => (current ? current.health() : { status: 'connecting', message: 'Restarting…' }),
|
|
};
|
|
},
|
|
|
|
routes() {
|
|
const unmounted = (res) => sendJson(res, 503, { error: 'ProdCom module is restarting — try again' });
|
|
return {
|
|
'GET /state': (req, res) => {
|
|
if (!current) return unmounted(res);
|
|
sendJson(res, 200, current.snapshot());
|
|
},
|
|
|
|
// The Send Message module reads this over loopback: where it may send.
|
|
'GET /messaging': (req, res) => {
|
|
if (!current) return unmounted(res);
|
|
sendJson(res, 200, current.messagingDescriptor());
|
|
},
|
|
|
|
// …and hands a message here, with the browser's unlock token. The token
|
|
// is checked with the Send Message module first: no PIN entered there,
|
|
// nothing goes out on a channel.
|
|
'POST /messaging/send': guard(async (req, res) => {
|
|
if (!current) return unmounted(res);
|
|
const v = await verifyUnlock(req, current.shellPort);
|
|
if (!v.ok) return sendJson(res, v.status, { error: v.error, code: v.code });
|
|
let body;
|
|
try {
|
|
body = await readJson(req);
|
|
} catch (err) {
|
|
return sendJson(res, 400, { error: err.message });
|
|
}
|
|
const text = String(body.text ?? '').replace(/\r\n/g, '\n').trim();
|
|
if (!text) return sendJson(res, 400, { error: 'Nothing to send' });
|
|
if (text.length > MAX_TEXT_CHARS) return sendJson(res, 400, { error: `Too long — ${MAX_TEXT_CHARS} characters at most` });
|
|
const ch = current.channelByRef(body.destination);
|
|
if (!ch) {
|
|
const s = String(body.destination ?? '').trim();
|
|
return sendJson(res, 400, { error: s ? `No ProdCom channel "${s}" — pick another in Send as` : 'Which ProdCom channel?', code: 'bad_destination' });
|
|
}
|
|
if (!ch.typingEnabled) {
|
|
return sendJson(res, 400, { error: `${ch.name} doesn't accept typed text — turn on typing for it in ProdCom`, code: 'typing_off' });
|
|
}
|
|
const out = await current.sendText(ch, text);
|
|
if (out.status !== 200) {
|
|
current.log(`send to ${ch.name} failed: ${out.error}`);
|
|
return sendJson(res, out.status, { error: out.error, code: out.code });
|
|
}
|
|
const preview = text.replace(/\s+/g, ' ').slice(0, 60);
|
|
current.log(`sent to ${ch.name}: "${preview}${text.length > 60 ? '…' : ''}"`);
|
|
sendJson(res, 200, { ok: true, label: ch.name, expiresAt: v.expiresAt });
|
|
}),
|
|
'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) {
|
|
res.write(`event: state\ndata: ${JSON.stringify({ status: { state: 'connecting', message: 'ProdCom module is restarting…' }, channels: [], groups: [], entries: [] })}\n\n`);
|
|
return void res.end();
|
|
}
|
|
res.write(`event: state\ndata: ${JSON.stringify(current.snapshot())}\n\n`);
|
|
try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
|
|
current.streams.add(res);
|
|
req.on('close', () => current && current.streams.delete(res));
|
|
},
|
|
};
|
|
},
|
|
};
|