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>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
3b81efcb51
commit
1274360ee6
+70
-9
@@ -1,9 +1,15 @@
|
||||
/**
|
||||
* Mock ProdCom API for verifying the prodcom-transcript module without a
|
||||
* live ProdCom. Serves the endpoints the module uses — channels, groups,
|
||||
* transcript history and the SSE stream — and simulates live speech:
|
||||
* entries appear in progress, grow word by word (same id, in-place update),
|
||||
* then complete.
|
||||
* 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)
|
||||
*
|
||||
@@ -17,12 +23,13 @@ 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' },
|
||||
{ id: 'ch-foh', name: 'FOH', color: '#5c9de0' },
|
||||
{ id: 'ch-video', name: 'Video', color: '#5cd08c' },
|
||||
{ id: 'ch-light', name: 'Lighting', color: '#e0b45c' },
|
||||
{ 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 = [
|
||||
@@ -69,6 +76,7 @@ function stepSimulation() {
|
||||
text: words[0],
|
||||
date: new Date().toISOString(),
|
||||
inProgress: true,
|
||||
source: 'audio',
|
||||
};
|
||||
speaking = { entry, words, at: 1 };
|
||||
history.push(entry);
|
||||
@@ -90,15 +98,68 @@ function stepSimulation() {
|
||||
|
||||
setInterval(stepSimulation, STEP_MS);
|
||||
|
||||
function sendJson(res, body) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
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') {
|
||||
|
||||
Reference in New Issue
Block a user