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:
Waters Church
2026-10-05 13:55:55 -04:00
co-authored by Claude Fable 5.1
parent 3b81efcb51
commit 1274360ee6
15 changed files with 3409 additions and 1528 deletions
+70 -9
View File
@@ -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') {