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
+217 -11
View File
@@ -11,19 +11,36 @@
* 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 /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). A bearer API key is sent when one is configured.
* 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;
@@ -67,13 +84,92 @@ function normalizeEntry(e) {
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 }) {
init({ config, log, shell }) {
const shellPort = Number(shell && shell.port) || 0;
const target = resolveTarget(config);
const apiKey = String(config.apiKey || '');
const channels = [];
@@ -148,7 +244,14 @@ module.exports = {
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' })));
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) : [] })));
}
@@ -264,9 +367,75 @@ module.exports = {
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
@@ -295,15 +464,52 @@ module.exports = {
},
routes() {
const sendJson = (res, code, body) => {
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
res.end(JSON.stringify(body));
};
const unmounted = (res) => sendJson(res, 503, { error: 'ProdCom module is restarting — try again' });
return {
'GET /state': (req, res) => {
if (!current) return sendJson(res, 503, { error: 'ProdCom module is restarting — try again' });
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',