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>
193 lines
7.3 KiB
JavaScript
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)`);
|
|
});
|