Files
ProdDash/tools/prodcom-mock.js
T
Waters ChurchandClaude Fable 5.1 1274360ee6 Send Message 1.0.0: one PIN, one place to send from; Slack 1.3.0 and ProdCom 1.4.0 become messaging providers
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>
2026-10-05 13:55:55 -04:00

193 lines
7.3 KiB
JavaScript

/**
* Mock ProdCom API for verifying the prodcom-transcript module without a
* live ProdCom. Serves the endpoints the module uses — channels, groups,
* transcript history, the SSE stream and POST /api/v1/channels/{id}/send —
* and simulates live speech: entries appear in progress, grow word by word
* (same id, in-place update), then complete. Text sent to a channel lands
* in that channel's history as a completed entry with source "typed" and
* goes out on the stream, which is how a send from the Send Message module
* can be watched arriving in the transcript tile. A text containing "!429"
* answers RATE_LIMITED; an unknown channel NOT_FOUND. With PRODCOM_MOCK_KEY
* set, every request must carry it as a Bearer token (UNAUTHORIZED
* otherwise) — the module's "API key" field.
*
* node tools/prodcom-mock.js [port] (default 24480)
*
* Point the module's admin config at http://127.0.0.1:<port>. Kill and
* restart this process while tiles are open to watch them degrade and
* recover on their own.
*/
'use strict';
const http = require('http');
const PORT = Number.parseInt(process.argv[2] || '24480', 10);
const STEP_MS = Number.parseInt(process.env.PRODCOM_STEP_MS || '700', 10);
const API_KEY = String(process.env.PRODCOM_MOCK_KEY || '');
const CHANNELS = [
{ id: 'ch-stage', name: 'Stage', color: '#e0645c', sourceType: 'localAudio', typingEnabled: true, isSelected: true, unreadCount: 0 },
{ id: 'ch-foh', name: 'FOH', color: '#5c9de0', sourceType: 'localAudio', typingEnabled: true, isSelected: false, unreadCount: 0 },
{ id: 'ch-video', name: 'Video', color: '#5cd08c', sourceType: 'network', typingEnabled: false, isSelected: false, unreadCount: 0 },
{ id: 'ch-light', name: 'Lighting', color: '#e0b45c', sourceType: 'network', typingEnabled: false, isSelected: false, unreadCount: 0 },
];
const GROUPS = [
{ id: 'g-band', name: 'Band', channelIds: ['ch-stage', 'ch-foh'] },
{ id: 'g-tech', name: 'Tech', channelIds: ['ch-video', 'ch-light'] },
];
const PHRASES = [
'Standby for the walk-in loop.',
'Vocals are hot on channel two, trim it down a touch please.',
'Camera three, give me a slow push on the worship leader.',
'House to half, stage wash up for the next song.',
'That was the last chorus, transition cue is next.',
'Confidence monitor is frozen, refreshing it now.',
'Great job everyone, that transition was clean.',
'Pastor is walking up in thirty seconds.',
];
let history = []; // completed + in-progress entries, oldest first
// Unique across mock restarts, so a reconnecting client's dedupe-by-id
// treats post-restart entries as new (like real ProdCom ids would be).
const RUN = Date.now().toString(36);
let nextId = 1;
const streams = new Set();
/** The one in-progress utterance, or null. */
let speaking = null; // { entry, words, at }
function broadcast(entry) {
const frame = 'data: ' + JSON.stringify({ type: entry.inProgress ? 'transcript.update' : 'transcript.completed', entry }) + '\n\n';
for (const res of streams) {
try { res.write(frame); } catch { streams.delete(res); }
}
}
function stepSimulation() {
if (!speaking) {
const channel = CHANNELS[Math.floor(Math.random() * CHANNELS.length)];
const words = PHRASES[Math.floor(Math.random() * PHRASES.length)].split(' ');
const entry = {
id: 'e' + RUN + '-' + nextId++,
channelId: channel.id,
channelName: channel.name,
text: words[0],
date: new Date().toISOString(),
inProgress: true,
source: 'audio',
};
speaking = { entry, words, at: 1 };
history.push(entry);
if (history.length > 200) history.shift();
broadcast(entry);
return;
}
const { entry, words } = speaking;
if (speaking.at < words.length) {
entry.text = words.slice(0, ++speaking.at).join(' ');
broadcast(entry);
} else {
entry.inProgress = false;
broadcast(entry);
console.log(`[prodcom-mock] ${entry.channelName}: ${entry.text}`);
speaking = null;
}
}
setInterval(stepSimulation, STEP_MS);
function sendJson(res, body, status = 200) {
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(body));
}
/** ProdCom's error envelope. */
function sendError(res, status, code, message) {
sendJson(res, { error: { code, message }, meta: { timestamp: new Date().toISOString() } }, status);
}
function readBody(req) {
return new Promise((resolve) => {
let data = '';
req.on('data', (c) => { data += c; });
req.on('end', () => {
try { resolve(data ? JSON.parse(data) : {}); } catch { resolve(null); }
});
req.on('error', () => resolve(null));
});
}
/** Typed text onto a channel: a completed entry with source "typed", like ProdCom makes. */
function sendText(channel, text) {
const entry = {
id: 'e' + RUN + '-' + nextId++,
channelId: channel.id,
channelName: channel.name,
text,
date: new Date().toISOString(),
inProgress: false,
source: 'typed',
hasBeenSeen: false,
};
history.push(entry);
if (history.length > 200) history.shift();
broadcast(entry);
console.log(`[prodcom-mock] typed → ${channel.name}: ${text}`);
}
const server = http.createServer((req, res) => {
const url = new URL(req.url, `http://x.invalid`);
const p = url.pathname;
if (API_KEY && req.headers.authorization !== `Bearer ${API_KEY}`) {
return sendError(res, 401, 'UNAUTHORIZED', 'Authentication required or invalid API key');
}
// POST /api/v1/channels/{id or name}/send { text }
const send = /^\/api\/v1\/channels\/([^/]+)\/send$/.exec(p);
if (send && req.method === 'POST') {
const ref = decodeURIComponent(send[1]);
const channel = CHANNELS.find((c) => c.id === ref) || CHANNELS.find((c) => c.name.toLowerCase() === ref.toLowerCase());
return void readBody(req).then((body) => {
if (!channel) return sendError(res, 404, 'NOT_FOUND', 'Channel not found');
const text = typeof body?.text === 'string' ? body.text.trim() : '';
if (!body || !text) return sendError(res, 400, 'VALIDATION_ERROR', 'text is required');
if (text.includes('!429')) return sendError(res, 429, 'RATE_LIMITED', 'Slow down');
sendText(channel, text);
sendJson(res, { data: { sent: true }, meta: { timestamp: new Date().toISOString() } });
});
}
if (p === '/api/v1/channels') return sendJson(res, { data: CHANNELS });
if (p === '/api/v1/groups') return sendJson(res, { data: GROUPS });
if (p === '/api/v1/transcript') {
const limit = Number.parseInt(url.searchParams.get('limit') || '100', 10) || 100;
return sendJson(res, { data: history.slice(-limit) });
}
if (p === '/api/v1/transcript/stream') {
res.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
});
res.write('retry: 3000\n\n');
streams.add(res);
console.log(`[prodcom-mock] stream client connected (${streams.size})`);
res.on('close', () => {
streams.delete(res);
console.log(`[prodcom-mock] stream client left (${streams.size})`);
});
return;
}
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'not found' }));
});
server.requestTimeout = 0;
server.listen(PORT, '127.0.0.1', () => {
console.log(`[prodcom-mock] ProdCom mock API on http://127.0.0.1:${PORT} (step ${STEP_MS}ms)`);
});