Files
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

807 lines
33 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/**
* Send Message — server part.
*
* One place a dashboard sends from, whatever the platform. The module owns
* nothing upstream: it finds every module whose manifest says
* "provides": ["messaging"] (Slack, ProdCom, whatever comes next) through
* the shell's /api/modules over loopback, reads each one's destinations
* from its GET /messaging, and hands a message to the provider's
* POST /messaging/send — so Slack keeps its one Slack connection, ProdCom
* its one ProdCom connection, and this module never opens a second one.
*
* It also owns the PIN. A browser unlocks here and gets a token; every
* gated action anywhere — a send through this module, a reaction in a
* Slack transcript — carries that token, and the provider checks it with
* GET /verify over loopback before touching its upstream. No provider has
* a PIN of its own, and no browser on the network can reach a provider's
* send route without having entered the PIN here.
*
* GET /state providers (destinations, identity, status), pin flags, quick emojis
* GET /stream SSE `state` events (change-detected, heartbeated)
* POST /unlock { pin } → { token, expiresAt } (constant-time, lockout)
* with the PIN switched off: {} → a token, no questions
* POST /lock Bearer → forget that token
* GET /verify Bearer → { ok, expiresAt }, 401 otherwise — for providers
* GET /quick Bearer → preset labels, indexes and bindings (never the text)
* POST /send Bearer, { provider, destination, text }
* POST /quick Bearer, { index, provider, destination }
*
* The provider contract is in docs/MODULE-GUIDE.md ("Offering messaging").
*/
const crypto = require('crypto');
const SELF_ID = 'send-message';
const CAPABILITY = 'messaging';
/** SSE comment ping cadence — keeps idle connections alive through sleepy Wi-Fi. */
const HEARTBEAT_MS = 15000;
/** Re-read /api/modules: a newly installed provider shows up without a restart. */
const DISCOVER_MS = 30000;
const DISCOVER_RETRY_MS = 5000; // …sooner while the shell isn't answering yet (boot)
const DISCOVER_FIRST_MS = 1500; // init() runs before the shell listens
/** Each provider's /messaging, re-read this often while a tile is watching… */
const DESCRIBE_MS = 4000;
/** …and this slowly with nobody looking. */
const DESCRIBE_IDLE_MS = 30000;
/** A tile counts as watching for this long after its last /state or /stream. */
const IDLE_AFTER_MS = 30000;
const LOOPBACK_TIMEOUT_MS = 5000;
/** A provider's send may itself wait on its upstream. */
const SEND_TIMEOUT_MS = 15000;
const PIN_RE = /^\d{4,8}$/;
const PIN_MAX_WRONG = 5;
const PIN_LOCKOUT_MS = 30000;
/** With the PIN off, a token is minted on request and simply lasts. */
const NO_PIN_TOKEN_MS = 7 * 24 * 60 * 60 * 1000;
const SEND_MIN_INTERVAL_MS = 1000;
const DEFAULT_MAX_CHARS = 4000;
const BODY_LIMIT_BYTES = 64 * 1024;
const UNLOCK_SWEEP_MS = 30000;
/** Set by init(), read by routes() — rebuilt together on remount. */
let current = null;
/* ── 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] : '';
}
function clientIp(req) {
return String(req.socket?.remoteAddress || '');
}
/** Constant-time PIN compare (lengths are folded in after the timing-safe step). */
function pinMatches(given, expected) {
const a = Buffer.alloc(16);
const b = Buffer.alloc(16);
a.write(String(given).slice(0, 16));
b.write(String(expected).slice(0, 16));
const same = crypto.timingSafeEqual(a, b);
return same && given.length === expected.length;
}
function errorText(err) {
return String(err?.cause?.code || err?.code || err?.message || err || 'error');
}
/**
* "Label | Message | platform:destination" per line. A line with no "|" is
* both its label and its message. The binding (optional) names a provider
* — its module id, a prefix of it ("prodcom"), or its label ("Slack") — and
* one of its destinations by id or label ("#production", "FOH"); a reply
* that carries one always goes there, whatever is selected.
*/
function parseQuickReplies(text) {
const out = [];
for (const rawLine of String(text || '').split(/\r?\n/)) {
const line = rawLine.trim();
if (!line) continue;
const parts = line.split('|').map((p) => p.trim());
const label = parts[0];
const message = parts.length > 1 && parts[1] ? parts[1] : label;
const binding = parts.length > 2 ? parts[2] : '';
if (!label || !message) continue;
out.push({ label, message, binding });
}
return out;
}
/** "👍 🙏 ✅" → ['👍', '🙏', '✅'] — plain text tokens, eight at most. */
function parseQuickEmojis(text) {
return String(text || '').split(/\s+/).filter(Boolean).slice(0, 8);
}
/** A provider's GET /messaging answer, as the tiles and the send route use it. */
function normalizeDescriptor(id, fallbackLabel, body) {
const b = body && typeof body === 'object' ? body : {};
const st = b.status && typeof b.status === 'object' ? b.status : {};
const state = st.state === 'ok' || st.state === 'error' ? st.state : 'connecting';
const destinations = [];
const seen = new Set();
for (const d of Array.isArray(b.destinations) ? b.destinations : []) {
if (!d || typeof d !== 'object') continue;
const did = String(d.id ?? '').trim();
if (!did || seen.has(did)) continue;
seen.add(did);
destinations.push({ id: did, label: String(d.label || did), color: typeof d.color === 'string' ? d.color : '' });
}
const maxChars = Math.trunc(Number(b.maxChars));
return {
id,
label: String(b.label || fallbackLabel || id),
identity: String(b.identity || ''),
// recipient: a message goes *to* a destination (a Slack channel);
// identity: it goes out *as* one (a ProdCom channel) — the Send as menu
// lists only the latter, and the wording follows
destinationRole: b.destinationRole === 'identity' ? 'identity' : 'recipient',
status: { state, message: String(st.message || '') },
destinations,
// what to say when the list is empty, if the provider knows why
emptyMessage: String(b.emptyMessage || ''),
maxChars: maxChars > 0 ? Math.min(DEFAULT_MAX_CHARS * 10, maxChars) : DEFAULT_MAX_CHARS,
};
}
/** "Slack → #ops" / "ProdCom as FOH" — how a provider + destination pair reads. */
function pairLabel(descriptor, destination) {
return descriptor.destinationRole === 'identity'
? `${descriptor.label} as ${destination.label}`
: `${descriptor.label} → ${destination.label}`;
}
/* ── the module ─────────────────────────────────────────────────────── */
module.exports = {
init({ config, log, shell }) {
/* ── config ── */
const shellPort = Number(shell && shell.port) || 0;
const pinEnabled = config.pinEnabled !== false;
const pin = String(config.pin || '').trim();
const pinOk = PIN_RE.test(pin);
const idleMs = Math.min(240, Math.max(1, Number(config.pinIdleMinutes) || 10)) * 60 * 1000;
const quickReplies = parseQuickReplies(config.quickReplies);
const quickEmojis = parseQuickEmojis(config.quickEmojis);
/* ── state (all inside this closure so a re-init starts clean) ── */
let stopped = false;
const providers = new Map(); // module id → { id, label, descriptor | null, error, at }
let listError = ''; // the last /api/modules failure ('' while fine)
let listedAt = 0;
let discoverTimer = null;
let describeTimer = null;
let describing = null; // one descriptor sweep in flight
const streams = new Set();
const unlocks = new Map(); // token → { expiresAt, lastSendAt, ip }
const pinAttempts = new Map(); // ip → { count, lockedUntil }
let lastInterest = Date.now();
let lastSig = '';
const loopback = (path) => `http://127.0.0.1:${shellPort}${path}`;
function watched() {
return streams.size > 0 || Date.now() - lastInterest < IDLE_AFTER_MS;
}
/* ── discovery: who offers messaging ── */
async function discover() {
discoverTimer = null;
if (stopped) return;
try {
if (!shellPort) throw new Error('shell port unknown (needs ProdDash 1.4.0)');
const res = await fetch(loopback('/api/modules'), {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(LOOPBACK_TIMEOUT_MS),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await res.json();
if (stopped) return;
const mods = (Array.isArray(body?.modules) ? body.modules : [])
.filter((m) => m && typeof m.id === 'string' && m.id !== SELF_ID && Array.isArray(m.provides) && m.provides.includes(CAPABILITY));
const keep = new Set();
for (const m of mods) {
keep.add(m.id);
const have = providers.get(m.id);
if (have) have.label = String(m.name || m.id);
else {
providers.set(m.id, { id: m.id, label: String(m.name || m.id), descriptor: null, error: '', at: 0 });
log(`module "${m.id}" offers messaging`);
}
}
for (const id of [...providers.keys()]) {
if (keep.has(id)) continue;
providers.delete(id);
log(`module "${id}" no longer offers messaging`);
}
listError = '';
listedAt = Date.now();
await describeAll();
} catch (err) {
listError = errorText(err);
broadcastIfChanged();
}
if (!stopped) {
discoverTimer = setTimeout(discover, listError ? DISCOVER_RETRY_MS : DISCOVER_MS);
discoverTimer.unref?.();
}
}
/** Read one provider's /messaging; a failure is kept on the entry, not thrown. */
async function describe(p) {
try {
const res = await fetch(loopback(`/api/modules/${encodeURIComponent(p.id)}/messaging`), {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(LOOPBACK_TIMEOUT_MS),
});
if (res.status === 404) {
// Disabled or uninstalled, most likely: let the module list decide
// now rather than at the next 30 s pass, so the tiles drop it at once.
if (!discoverTimer && !stopped) {
discoverTimer = setTimeout(discover, 250);
discoverTimer.unref?.();
}
throw new Error('not answering (disabled or restarting)');
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await res.json();
if (stopped) return;
p.descriptor = normalizeDescriptor(p.id, p.label, body);
if (p.error) log(`${p.label}: describing itself again`);
p.error = '';
p.at = Date.now();
} catch (err) {
const text = errorText(err);
if (p.error !== text) log(`${p.label}: cannot read its destinations — ${text}`);
p.error = text;
}
}
function describeAll() {
if (!describing) {
describing = Promise.all([...providers.values()].map(describe))
.then(() => broadcastIfChanged())
.finally(() => { describing = null; });
}
return describing;
}
function scheduleDescribe(delay = null) {
if (stopped) return;
clearTimeout(describeTimer);
describeTimer = setTimeout(async () => {
describeTimer = null;
await describeAll();
scheduleDescribe();
}, delay ?? (watched() ? DESCRIBE_MS : DESCRIBE_IDLE_MS));
describeTimer.unref?.();
}
/** Somebody is looking: catch up at once if the list is stale or empty. */
function wake() {
lastInterest = Date.now();
if (stopped) return;
if ((!providers.size || listError) && Date.now() - listedAt > DISCOVER_RETRY_MS && !discoverTimer) {
discoverTimer = setTimeout(discover, 50);
discoverTimer.unref?.();
} else if (describeTimer) {
// The idle cadence was in force; move to the watched one now.
scheduleDescribe(DESCRIBE_MS);
}
}
/* ── what the tiles see ── */
function providerView(p) {
const d = p.descriptor;
if (!d) {
return {
id: p.id,
label: p.label,
identity: '',
destinationRole: 'recipient',
status: { state: p.error ? 'error' : 'connecting', message: p.error ? `${p.label} module ${p.error}` : 'Reading destinations…' },
destinations: [],
emptyMessage: '',
maxChars: DEFAULT_MAX_CHARS,
error: p.error,
};
}
return { ...d, error: p.error, status: p.error ? { state: 'error', message: `${p.label} module ${p.error}` } : d.status };
}
function health() {
if (!shellPort) return { status: 'error', message: 'This module needs ProdDash 1.4.0 or newer' };
const pinLine = !pinEnabled ? 'PIN off' : pinOk ? 'PIN set' : 'PIN on but not set — sending refused';
if (listError) return { status: listedAt ? 'connecting' : 'error', message: `Cannot read the module list (${listError}) · ${pinLine}` };
if (!listedAt) return { status: 'connecting', message: 'Looking for messaging modules…' };
const list = [...providers.values()];
if (!list.length) return { status: 'error', message: `No module offers messaging — install Slack or ProdCom · ${pinLine}` };
const parts = list.map((p) => {
const v = providerView(p);
if (v.error) return `${v.label}: ${v.error}`;
const n = v.destinations.length;
const who = v.identity ? ` as ${v.identity}` : '';
// a provider that is up but unhappy says why, so the admin line does too
const why = v.status.state !== 'ok' && v.status.message ? ` — ${v.status.message}`
: !n && v.emptyMessage ? ` — ${v.emptyMessage}` : '';
const noun = v.destinationRole === 'identity' ? 'identit' + (n === 1 ? 'y' : 'ies') : 'destination' + (n === 1 ? '' : 's');
return `${v.label}${who} (${n} ${noun})${why}`;
});
const bad = list.some((p) => p.error || p.descriptor?.status.state === 'error');
const pending = list.some((p) => !p.descriptor);
return {
status: pinEnabled && !pinOk ? 'error' : bad ? 'error' : pending ? 'connecting' : 'ok',
message: `${parts.join(' · ')} · ${pinLine}`,
};
}
function snapshot() {
const h = health();
return {
providers: [...providers.values()].map(providerView),
pin: {
enabled: pinEnabled,
set: pinOk,
// the pad submits by itself once this many digits are in (never the PIN)
length: pinEnabled && pinOk ? pin.length : 0,
idleMinutes: idleMs / 60000,
},
quickEmojis,
quickCount: quickReplies.length,
status: { state: h.status, message: h.message },
};
}
function broadcastIfChanged() {
const snap = snapshot();
const sig = JSON.stringify(snap);
if (sig === lastSig) return;
lastSig = sig;
if (!streams.size) return;
const payload = `event: state\ndata: ${JSON.stringify(snap)}\n\n`;
for (const res of streams) {
try {
res.write(payload);
} catch {
streams.delete(res);
}
}
}
/* ── the PIN and its unlock tokens ── */
function sweepUnlocks() {
const now = Date.now();
for (const [t, u] of unlocks) if (u.expiresAt <= now) unlocks.delete(t);
for (const [ip, a] of pinAttempts) if (a.lockedUntil && a.lockedUntil <= now && !a.count) pinAttempts.delete(ip);
}
/** The unlock behind a token, or null; a hit slides its idle expiry along. */
function verify(token) {
const u = token ? unlocks.get(token) : null;
if (!u) return null;
const now = Date.now();
if (u.expiresAt <= now) {
unlocks.delete(token);
return null;
}
if (pinEnabled) u.expiresAt = now + idleMs;
return { token, ...u };
}
/** Everything a send needs before anyone is asked; answers the refusal itself. */
function sendGate(req, res) {
if (pinEnabled && !pinOk) {
sendJson(res, 403, { error: 'Set a PIN in Admin → Send Message, or switch the PIN off', code: 'no_pin' });
return null;
}
const u = verify(bearer(req));
if (!u) {
sendJson(res, 401, { error: 'Locked — enter the PIN', code: 'locked' });
return null;
}
return u;
}
function unlock(req, res, given) {
const ip = clientIp(req);
const now = Date.now();
if (!pinEnabled) {
const t = crypto.randomBytes(32).toString('hex');
const expiresAt = now + NO_PIN_TOKEN_MS;
unlocks.set(t, { expiresAt, lastSendAt: 0, ip });
return sendJson(res, 200, { token: t, expiresAt, idleMs: NO_PIN_TOKEN_MS, pinEnabled: false });
}
if (!pinOk) return sendJson(res, 403, { error: 'Set a PIN in Admin → Send Message, or switch the PIN off', code: 'no_pin' });
let a = pinAttempts.get(ip);
if (a && a.lockedUntil > now) {
const secs = Math.ceil((a.lockedUntil - now) / 1000);
return sendJson(res, 429, { error: `Too many wrong PINs — locked for ${secs} s`, code: 'lockout', retryAfter: secs });
}
if (a && a.lockedUntil && a.lockedUntil <= now) a = null; // lockout served — clean slate
const str = String(given ?? '');
if (!PIN_RE.test(str) || !pinMatches(str, pin)) {
const count = (a ? a.count : 0) + 1;
if (count >= PIN_MAX_WRONG) {
pinAttempts.set(ip, { count: 0, lockedUntil: now + PIN_LOCKOUT_MS });
log(`wrong PIN ×${PIN_MAX_WRONG} from ${ip} — locked for ${PIN_LOCKOUT_MS / 1000} s`);
return sendJson(res, 429, { error: `Wrong PIN — locked for ${PIN_LOCKOUT_MS / 1000} s`, code: 'lockout', retryAfter: PIN_LOCKOUT_MS / 1000 });
}
pinAttempts.set(ip, { count, lockedUntil: 0 });
return sendJson(res, 401, { error: 'Wrong PIN', code: 'wrong_pin', triesLeft: PIN_MAX_WRONG - count });
}
pinAttempts.delete(ip);
const t = crypto.randomBytes(32).toString('hex');
const expiresAt = now + idleMs;
unlocks.set(t, { expiresAt, lastSendAt: 0, ip });
log(`unlocked from ${ip} (auto-lock after ${idleMs / 60000} min idle)`);
return sendJson(res, 200, { token: t, expiresAt, idleMs, pinEnabled: true });
}
/* ── destinations and bindings ── */
function findProvider(ref) {
const s = String(ref ?? '').trim().toLowerCase();
if (!s) return null;
for (const p of providers.values()) if (p.id.toLowerCase() === s) return p;
for (const p of providers.values()) {
const label = (p.descriptor?.label || p.label).toLowerCase();
if (label === s || p.id.toLowerCase().startsWith(s) || label.replace(/\s+/g, '') === s.replace(/\s+/g, '')) return p;
}
return null;
}
function findDestination(p, ref) {
const s = String(ref ?? '').trim();
if (!p?.descriptor || !s) return null;
const list = p.descriptor.destinations;
const exact = list.find((d) => d.id === s);
if (exact) return exact;
const name = s.replace(/^#/, '').toLowerCase();
return list.find((d) => d.label.replace(/^#/, '').toLowerCase() === name) || null;
}
/** A quick reply's "platform:destination" → { provider, destination } or { error }. */
function resolveBinding(binding) {
const s = String(binding || '').trim();
if (!s) return null;
const i = s.indexOf(':');
if (i > 0) {
const p = findProvider(s.slice(0, i));
if (!p) return { error: `No messaging module called "${s.slice(0, i).trim()}" for this quick reply` };
if (!p.descriptor) return { error: `${p.label} hasn't listed its destinations yet — try again in a moment` };
const d = findDestination(p, s.slice(i + 1));
if (!d) return { error: `${p.descriptor.label} has no destination "${s.slice(i + 1).trim()}" — check the quick reply in Admin → Send Message` };
return { provider: p, destination: d };
}
for (const p of providers.values()) {
const d = findDestination(p, s);
if (d) return { provider: p, destination: d };
}
return { error: `No destination "${s}" on any messaging module — check the quick reply in Admin → Send Message` };
}
/** The label a tile shows for a binding — resolved when it can be, the raw text otherwise. */
function bindingLabel(binding) {
const r = resolveBinding(binding);
if (!r) return '';
if (r.error) return String(binding).trim();
return pairLabel(r.provider.descriptor, r.destination);
}
/**
* The provider and destination a browser asked for. Both must be ones
* the module has listed — a PIN-unlocked browser chooses among what the
* providers offer, never an arbitrary id.
*/
async function chooseTarget(providerRef, destinationRef) {
const p = findProvider(providerRef);
if (!p) {
const s = String(providerRef ?? '').trim();
return { error: s ? `${s} isn't offering messaging right now — is the module enabled?` : 'Pick where to send first' };
}
if (!p.descriptor) await describe(p);
if (!p.descriptor) return { error: `${p.label} module ${p.error || 'is not answering'}` };
let d = findDestination(p, destinationRef);
if (!d && String(destinationRef ?? '').trim()) {
await describe(p); // a channel added a moment ago
d = findDestination(p, destinationRef);
}
if (!d) {
const s = String(destinationRef ?? '').trim();
return { error: s ? `${p.descriptor.label} has no destination "${s}" — pick another in Send as` : `Pick a ${p.descriptor.label} destination in Send as` };
}
return { provider: p, destination: d };
}
/* ── delivery: hand the message to the provider over loopback ── */
async function deliver(req, res, unlock, { provider, destination, text, what }) {
const now = Date.now();
if (now - unlock.lastSendAt < SEND_MIN_INTERVAL_MS) {
return sendJson(res, 429, { error: 'One message per second — try again', code: 'too_fast' });
}
const u = unlocks.get(unlock.token);
if (u) u.lastSendAt = now;
const max = provider.descriptor?.maxChars || DEFAULT_MAX_CHARS;
if (text.length > max) return sendJson(res, 400, { error: `Too long for ${provider.descriptor.label} — ${max} characters at most`, code: 'too_long' });
const label = provider.descriptor.label;
let answer;
try {
const r = await fetch(loopback(`/api/modules/${encodeURIComponent(provider.id)}/messaging/send`), {
method: 'POST',
headers: {
'Content-Type': 'application/json; charset=utf-8',
Accept: 'application/json',
Authorization: `Bearer ${unlock.token}`,
},
body: JSON.stringify({ destination: destination.id, text }),
signal: AbortSignal.timeout(SEND_TIMEOUT_MS),
});
let body = {};
try { body = await r.json(); } catch { /* a non-JSON refusal */ }
answer = { status: r.status, body: body && typeof body === 'object' ? body : {} };
} catch (err) {
const message = `${label} module unreachable (${errorText(err)}) — not sent`;
log(`${what} → ${pairLabel(provider.descriptor, destination)} failed: ${message}`);
return sendJson(res, 502, { error: message, code: 'provider_unreachable' });
}
if (answer.status === 404) {
return sendJson(res, 502, { error: `${label} module isn't answering (disabled or restarting) — not sent`, code: 'provider_gone' });
}
if (answer.status < 200 || answer.status >= 300 || answer.body.ok === false) {
const message = String(answer.body.error || `${label} answered HTTP ${answer.status}`);
log(`${what} → ${pairLabel(provider.descriptor, destination)} failed: ${message}`);
const status = answer.status === 401 ? 401 : [400, 404, 429].includes(answer.status) ? answer.status : 502;
return sendJson(res, status, { error: message, code: answer.body.code || `http_${answer.status}` });
}
const preview = text.replace(/\s+/g, ' ').slice(0, 60);
log(`${what} → ${pairLabel(provider.descriptor, destination)}: "${preview}${text.length > 60 ? '…' : ''}"`);
scheduleDescribe(500); // the provider may have something new to say about itself
return sendJson(res, 200, {
ok: true,
provider: provider.id,
providerLabel: label,
destination: destination.id,
label: String(answer.body.label || destination.label),
role: provider.descriptor.destinationRole,
expiresAt: u ? u.expiresAt : unlock.expiresAt,
});
}
/* ── go ── */
const heartbeat = setInterval(() => {
for (const res of streams) {
try {
res.write(': ping\n\n');
} catch {
streams.delete(res);
}
}
}, HEARTBEAT_MS);
heartbeat.unref?.();
const sweeper = setInterval(sweepUnlocks, UNLOCK_SWEEP_MS);
sweeper.unref?.();
if (!shellPort) log('the shell did not say which port it serves on — this module needs ProdDash 1.4.0 or newer');
else {
if (pinEnabled && !pinOk) log('the PIN is on but not set (4–8 digits) — send tiles stay locked until it is, or until the PIN is switched off');
if (!pinEnabled) log('PIN off — send tiles send without asking');
discoverTimer = setTimeout(discover, DISCOVER_FIRST_MS);
discoverTimer.unref?.();
scheduleDescribe();
}
current = {
streams,
snapshot,
health,
wake,
verify,
sendGate,
unlock,
chooseTarget,
deliver,
resolveBinding,
bindingLabel,
quickReplies,
lock(req, res) {
const t = bearer(req);
if (t) unlocks.delete(t);
return sendJson(res, 200, { ok: true });
},
};
return {
stop() {
stopped = true;
clearTimeout(discoverTimer);
clearTimeout(describeTimer);
clearInterval(heartbeat);
clearInterval(sweeper);
unlocks.clear(); // an admin Apply locks every send tile
for (const res of streams) {
try { res.end(); } catch { /* already gone */ }
}
streams.clear();
if (current && current.streams === streams) current = null;
},
health,
};
},
/** The picker: the two send surfaces, one of each per dashboard. */
tiles() {
return [
{
id: 'send',
name: 'Send message',
description: 'A message to the transcript clicked last, on any platform',
single: true,
defaultSize: { w: 3, h: 3 },
minSize: { w: 2, h: 2 },
settings: { view: 'send' },
instanceSchema: {
showEmojis: { type: 'switch', label: 'Quick emojis', default: true },
},
},
{
id: 'quick',
name: 'Quick replies',
description: 'Preset buttons, one press each',
single: true,
defaultSize: { w: 3, h: 2 },
minSize: { w: 2, h: 1 },
settings: { view: 'quick' },
instanceSchema: {},
},
];
},
routes() {
const unmounted = (res) => sendJson(res, 503, { error: 'Send Message module is restarting — try again' });
return {
'GET /state': (req, res) => {
if (!current) return unmounted(res);
current.wake();
sendJson(res, 200, current.snapshot());
},
'GET /stream': (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
});
res.write('retry: 3000\n\n');
if (!current) {
res.write(`event: state\ndata: ${JSON.stringify({ providers: [], pin: { enabled: true, set: false, length: 0 }, quickEmojis: [], status: { state: 'connecting', message: 'Send Message module is restarting…' } })}\n\n`);
return void res.end();
}
res.write(`event: state\ndata: ${JSON.stringify(current.snapshot())}\n\n`);
try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
current.streams.add(res);
current.wake();
req.on('close', () => current && current.streams.delete(res));
},
'POST /unlock': guard(async (req, res) => {
if (!current) return unmounted(res);
let body;
try {
body = await readJson(req);
} catch (err) {
return sendJson(res, 400, { error: err.message });
}
current.unlock(req, res, body.pin);
}),
'POST /lock': (req, res) => {
if (!current) return unmounted(res);
current.lock(req, res);
},
// Providers ask this over loopback before an upstream action: is this unlock good?
'GET /verify': (req, res) => {
if (!current) return unmounted(res);
const u = current.verify(bearer(req));
if (!u) return sendJson(res, 401, { ok: false, error: 'Locked — enter the PIN', code: 'locked' });
sendJson(res, 200, { ok: true, expiresAt: u.expiresAt });
},
// Labels, indexes and bindings only — the message text never leaves the server.
'GET /quick': (req, res) => {
if (!current) return unmounted(res);
const unlock = current.sendGate(req, res);
if (!unlock) return;
sendJson(res, 200, {
presets: current.quickReplies.map((q, index) => ({ index, label: q.label, target: current.bindingLabel(q.binding) })),
expiresAt: unlock.expiresAt,
});
},
'POST /send': guard(async (req, res) => {
if (!current) return unmounted(res);
const unlock = current.sendGate(req, res);
if (!unlock) return;
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' });
const target = await current.chooseTarget(body.provider, body.destination);
if (target.error) return sendJson(res, 400, { error: target.error, code: 'bad_target' });
await current.deliver(req, res, unlock, { ...target, text, what: 'sent' });
}),
'POST /quick': guard(async (req, res) => {
if (!current) return unmounted(res);
const unlock = current.sendGate(req, res);
if (!unlock) return;
let body;
try {
body = await readJson(req);
} catch (err) {
return sendJson(res, 400, { error: err.message });
}
const index = Math.trunc(Number(body.index));
const preset = Number.isInteger(index) ? current.quickReplies[index] : null;
if (!preset) return sendJson(res, 404, { error: 'No such quick reply — the list may have changed in Admin' });
// A bound reply ignores the selection; an unbound one needs it.
let target = current.resolveBinding(preset.binding);
if (!target) target = await current.chooseTarget(body.provider, body.destination);
if (target.error) return sendJson(res, 400, { error: target.error, code: 'bad_target' });
await current.deliver(req, res, unlock, { ...target, text: preset.message, what: `quick reply "${preset.label}"` });
}),
};
},
};