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
+108 -339
View File
@@ -6,26 +6,29 @@
* One Slack Web API connection per ProdDash machine — the campus's shared
* location account (a user token), or a bot — polling the configured
* channels' recent history (one poller per channel, all inside one rate
* budget) and fanning the lot out to every tile over SSE, plus PIN-gated
* sending from this machine so a message reads as coming from the location.
* Browsers never talk to Slack; the token and the PIN stay here.
* budget) and fanning the lot out to every tile over SSE. Browsers never
* talk to Slack; the token stays here.
*
* GET /state snapshot: identity, channels (keyed by id), sending state
* GET /stream SSE `state` events (change-detected, heartbeated)
* GET /channels admin multiselect options — the channels the account is in
* POST /unlock { pin } → { token, expiresAt } (constant-time, lockout)
* POST /lock Bearer → forget that unlock token
* GET /quick Bearer → preset labels + indexes (never the message text)
* POST /send Bearer, { text, channel } → chat.postMessage
* POST /react Bearer, { channel, ts, name, remove? } → reactions.add / .remove
* GET /emoji { standard: {code: emoji}, custom: {name: {url}|{char}}, version }
* Sending is not this module's: the Send Message module owns the PIN, the
* send tiles and the quick replies, and hands a message to this module's
* POST /messaging/send over loopback, so it still leaves through this one
* connection, as the location. Every outbound action — a message, an emoji
* reaction from a transcript — carries that module's unlock token and is
* checked with it (GET /verify, over loopback) before Slack is asked. This
* module has no PIN of its own and cannot post without one entered there.
*
* GET /state snapshot: identity, channels (keyed by id)
* GET /stream SSE `state` events (change-detected, heartbeated)
* GET /channels admin multiselect options — the channels the account is in
* GET /messaging the configured channels as destinations (the module guide's contract)
* POST /messaging/send Bearer (Send Message unlock), { destination, text } → chat.postMessage
* POST /react Bearer (Send Message unlock), { channel, ts, name, remove? } → reactions.add / .remove
* GET /emoji { standard: {code: emoji}, custom: {name: {url}|{char}}, version }
* GET /emoji-image/<name>, GET /avatar/<u/USER|b/BOT> images proxied from Slack's CDN
* POST /quick Bearer, { index, channel } → post that preset
*
* `channel` on /send and /quick must be one of the configured channel ids
* (400 otherwise): a PIN-unlocked browser chooses among the admin's channels,
* never an arbitrary one. A quick reply with its own "| #channel" ignores the
* request's channel — an explicit binding wins over the selection.
* `destination` / `channel` must be one of the configured channel ids (400
* otherwise): an unlocked browser chooses among the admin's channels, never
* an arbitrary one.
*
* Rate budget. Slack allows an internal app roughly 50+ conversations.history
* calls a minute per token (Tier 3). The module keeps all its pollers under
@@ -46,8 +49,6 @@
* tools/slack-mock.js for development.
*/
const crypto = require('crypto');
const DEFAULT_API_BASE = 'https://slack.com/api';
const API_BASE = String(process.env.SLACK_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, '');
@@ -92,14 +93,11 @@ const FIRST_READ_STAGGER_MS = 250;
const MIN_SLOT_GAP_MS = 200;
/** tiles() waits at most this long for the first conversations.info answers after boot. */
const NAMES_WAIT_MS = 1500;
const PIN_RE = /^\d{4,8}$/;
const PIN_MAX_WRONG = 5;
const PIN_LOCKOUT_MS = 30000;
const SEND_MIN_INTERVAL_MS = 1000;
const MAX_TEXT_CHARS = 4000;
const BODY_LIMIT_BYTES = 64 * 1024;
const DEFAULT_ICON = ':satellite_antenna:';
const UNLOCK_SWEEP_MS = 30000;
/** The module that owns the PIN and the unlock tokens — see verifyUnlock(). */
const SENDER_ID = 'send-message';
/** Set by init(), read by routes() and tiles() — all rebuilt together on remount. */
let current = null;
@@ -238,7 +236,6 @@ const EMOJI_NAMES = require('./emoji.json').names;
const EMOJI_CODE_RE = /^([a-z0-9_+-]{1,100})(?:::(skin-tone-[2-6]))?$/i;
/** How long the workspace's custom emoji list is believed before emoji.list is asked again. */
const EMOJI_LIST_TTL_MS = 60 * 60 * 1000;
let emojiReverse = null; // character (variation selector stripped) → the name Slack knows it by
/** The character for a standard code, '' for a custom or unknown one. */
function emojiChar(code) {
@@ -250,45 +247,6 @@ function emojiChar(code) {
return tone ? base.replace(/\uFE0F/g, '') + tone : base;
}
/** The short name for a character ('' if Slack has none) — a reaction needs the name, not the glyph. */
function emojiName(char) {
if (!emojiReverse) {
emojiReverse = new Map();
for (const [name, ch] of Object.entries(EMOJI_NAMES)) {
if (name.startsWith('skin-tone-')) continue;
const key = ch.replace(/\uFE0F/g, '');
if (!emojiReverse.has(key)) emojiReverse.set(key, name); // keys are sorted: "+1" wins over "thumbsup"
}
}
const s = String(char || '').replace(/\uFE0F/g, '');
const tone = /[\u{1F3FB}-\u{1F3FF}]$/u.exec(s);
if (tone) {
const base = emojiReverse.get(s.slice(0, -tone[0].length));
const toneName = Object.keys(EMOJI_NAMES).find((n) => n.startsWith('skin-tone-') && EMOJI_NAMES[n] === tone[0]);
return base && toneName ? `${base}::${toneName}` : '';
}
return emojiReverse.get(s) || '';
}
/**
* The admin's "Quick emojis" — "👍 🙏 :white_check_mark: :party_parrot:" →
* [{ name, char }]. A standard emoji gets both; a custom code keeps its name
* and no character (the browser draws the image); a glyph Slack has no name
* for can be sent but not reacted with (name ''). Eight at most.
*/
function parseQuickEmojis(text) {
const out = [];
for (const tok of String(text || '').split(/\s+/).filter(Boolean)) {
const code = /^:([^:\s]+(?:::skin-tone-[2-6])?):$/i.exec(tok);
if (code) {
const name = code[1].toLowerCase();
if (EMOJI_CODE_RE.test(name)) out.push({ name, char: emojiChar(name) });
} else out.push({ name: emojiName(tok), char: tok });
if (out.length >= 8) break;
}
return out;
}
/* Avatars and custom emoji are images on Slack's CDN; browsers only talk to
ProdDash, so the module fetches them and keeps them a while. */
const IMAGE_CACHE_MAX = 400;
@@ -453,38 +411,30 @@ function bearer(req) {
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;
}
/**
* "Label | Message | #channel" per line. A line with no "|" is both its label
* and its message; the channel (optional) is a name the account can see —
* a reply that carries one always goes there, whatever transcript is selected.
* 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.
*/
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 channel = parts.length > 2 ? parts[2].replace(/^#/, '') : '';
if (!label || !message) continue;
out.push({ label, message, channel });
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' };
}
return out;
if (res.status === 404) return { ok: false, status: 503, error: 'Install the Send Message module to send or react 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 };
}
const SYSTEM_SUBTYPES = new Set([
@@ -497,8 +447,9 @@ const SYSTEM_SUBTYPES = new Set([
/* ── the module ─────────────────────────────────────────────────────── */
module.exports = {
init({ config, log }) {
init({ config, log, shell }) {
/* ── config ── */
const shellPort = Number(shell && shell.port) || 0;
const token = String(config.token || '').trim();
const sendAs = String(config.sendAs || '').trim();
const channelIds = configuredChannels(config);
@@ -508,12 +459,6 @@ module.exports = {
const historyCount = Math.min(HISTORY_MAX, Math.max(HISTORY_MIN, Math.trunc(Number(config.historyCount)) || 40));
const includeReplies = Boolean(config.includeReplies);
const threadFetchesPerPoll = Math.max(1, Math.floor(THREAD_FETCHES_PER_POLL / Math.max(1, channelIds.length)));
const sendEnabled = Boolean(config.sendEnabled);
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;
@@ -531,8 +476,6 @@ module.exports = {
let lastInterest = Date.now();
const epoch = Date.now(); // the phase-locked slots count from here
const streams = new Set();
const unlocks = new Map(); // token -> { expiresAt, lastSendAt, ip }
const pinAttempts = new Map(); // ip -> { count, lockedUntil }
/**
* One channel's poller: its own window, thread cache, timer and error,
@@ -724,21 +667,6 @@ module.exports = {
}
}
/** "#name" or a channel id → the channel id, or '' when the account can't see it. */
async function resolveChannelRef(ref) {
const s = String(ref || '').trim();
if (!s) return '';
if (/^[CG][A-Z0-9]{6,}$/.test(s)) return s;
const name = s.replace(/^#/, '').toLowerCase();
let list = await loadChannelList();
let hit = list.find((c) => c.name.toLowerCase() === name);
if (!hit) {
list = await loadChannelList(true);
hit = list.find((c) => c.name.toLowerCase() === name);
}
return hit ? hit.id : '';
}
/* ── normalisation ── */
function nameFor(m) {
if (m.subtype === 'bot_message' && m.username) return String(m.username);
@@ -845,7 +773,7 @@ module.exports = {
return { state: 'ok', message: `${labelOf(ch)} · live` };
}
/** The admin health line: who, which channels, what's wrong, sending. */
/** The admin health line: who, which channels, what's wrong. */
function health() {
if (!token) return { status: 'error', message: 'No Slack token — paste the location account’s User OAuth Token in Admin → Slack' };
if (authError) return { status: 'error', message: authError };
@@ -854,9 +782,8 @@ module.exports = {
const list = [...pollers.values()];
if (!list.length) return { status: 'error', message: `${who} · pick channels in Admin → Slack → Channels` };
const names = list.map(labelOf).join(', ');
const sending = !sendEnabled ? 'sending off' : !pinOk ? 'sending on — set a 4–8 digit PIN' : 'sending on';
const problems = [...new Set(list.filter((ch) => ch.error).map((ch) => (ch.error.includes(labelOf(ch)) ? ch.error : `${labelOf(ch)}: ${ch.error}`)))];
if (problems.length) return { status: 'error', message: `${who} · ${names} · ${problems.join(' · ')} · ${sending}` };
if (problems.length) return { status: 'error', message: `${who} · ${names} · ${problems.join(' · ')}` };
if (list.some((ch) => !ch.lastPollAt)) return { status: 'connecting', message: `${who} · ${names} · first read…` };
const stale = list.filter(isStale);
if (stale.length) {
@@ -864,7 +791,7 @@ module.exports = {
return { status: 'connecting', message: `${who} · ${names} · ${ago}` };
}
const cadence = intervalMs > pollMs ? ` · every ${intervalMs / 1000} s (rate budget)` : '';
return { status: 'ok', message: `${who} · ${names}${cadence} · ${sending}` };
return { status: 'ok', message: `${who} · ${names}${cadence}` };
}
function channelView(ch) {
@@ -899,10 +826,6 @@ module.exports = {
includeReplies,
historyCount,
pollSeconds: intervalMs / 1000,
sending: { enabled: sendEnabled, pinSet: pinOk, idleMinutes: idleMs / 60000 },
// the pad submits by itself once this many digits are in (never the PIN)
pinLength: pinOk ? pin.length : 0,
quickEmojis,
emojiVersion,
status: { state: h.status, message: h.message, lastPollAt },
apiBase: API_BASE,
@@ -1135,57 +1058,17 @@ module.exports = {
]);
}
/* ── 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 request, or null (the client re-asks for the PIN). */
function unlockFor(req) {
const t = bearer(req);
const u = t ? unlocks.get(t) : null;
if (!u) return null;
if (u.expiresAt <= Date.now()) {
unlocks.delete(t);
return null;
}
return { token: t, ...u };
}
/** Everything a send needs before Slack is asked; answers the response itself when refusing. */
function sendGate(req, res) {
if (!sendEnabled) {
sendJson(res, 403, { error: 'Sending is off — turn it on in Admin → Slack → Sending', code: 'sending_off' });
return null;
}
if (!pinOk) {
sendJson(res, 403, { error: 'Set a PIN in Admin → Slack → Sending', code: 'no_pin' });
return null;
}
const u = unlockFor(req);
if (!u) {
sendJson(res, 401, { error: 'Locked — enter the PIN', code: 'locked' });
return null;
}
return u;
}
/**
* The channel a browser asked to post to. Only a configured channel id is
* accepted — the selection is a choice among the admin's channels, never
* a way to reach some other one. Nothing asked for → the first configured.
* The channel a browser (or the Send Message module) asked to post to.
* Only a configured channel id is accepted — the selection is a choice
* among the admin's channels, never a way to reach some other one.
*/
function chooseTarget(requested) {
const s = String(requested ?? '').trim();
if (s) {
if (pollers.has(s)) return { id: s };
const name = lookupChannel(s);
return { error: `${name ? `#${name}` : s} isn't one of the configured channels — pick it in Admin → Slack → Channels` };
}
if (!channelIds.length) return { error: 'No channel configured — pick channels in Admin → Slack → Channels' };
return { id: channelIds[0] };
if (!s) return { error: 'Which channel?' };
if (pollers.has(s)) return { id: s };
const name = lookupChannel(s);
return { error: `${name ? `#${name}` : s} isn't one of the configured channels — pick it in Admin → Slack → Channels` };
}
async function postMessage(targetChannelId, text) {
@@ -1197,17 +1080,30 @@ module.exports = {
return slackCall(token, 'chat.postMessage', params, { json: true });
}
/** What the Send Message module may send to: the configured channels, as destinations. */
function messagingDescriptor() {
const h = health();
return {
label: 'Slack',
identity: sendingAs(),
destinationRole: 'recipient', // a channel is where a message goes, not who sends it
status: { state: h.status, message: h.message },
destinations: channelIds.map((id) => ({ id, label: `#${lookupChannel(id) || id}` })),
maxChars: MAX_TEXT_CHARS,
};
}
/**
* Add or remove a reaction on behalf of an unlock — the same gate as a
* send, since it is the location's name on it. The message's channel must
* be one of the configured ones. `already_reacted` / `no_reaction` count
* as done: Slack already holds the state that was asked for.
* Add or remove a reaction on behalf of a verified unlock — the same
* gate as a send, since it is the location's name on it. The message's
* channel must be one of the configured ones. `already_reacted` /
* `no_reaction` count as done: Slack already holds the state asked for.
*/
async function react(req, res, unlock, { channel, ts, name, remove }) {
async function react(req, res, verified, { channel, ts, name, remove }) {
if (!identity || authError) {
return sendJson(res, 502, { error: authError || 'Slack token not checked yet — try again in a moment' });
}
const target = String(channel ?? '').trim() ? chooseTarget(channel) : { error: 'Which channel?' };
const target = chooseTarget(channel);
if (target.error) return sendJson(res, 400, { error: target.error, code: 'bad_channel' });
const stamp = String(ts ?? '').trim();
if (!/^\d{1,16}\.\d{1,9}$/.test(stamp)) return sendJson(res, 400, { error: 'Which message?' });
@@ -1225,52 +1121,30 @@ module.exports = {
return sendJson(res, status, { error: message, code: err.code });
}
}
const u = unlocks.get(unlock.token);
if (u) u.expiresAt = Date.now() + idleMs;
log(`${remove ? 'removed' : 'added'} :${code}: on ${stamp} in #${targetName} as ${sendingAs()}`);
if (pollers.has(target.id)) pollSoon(pollers.get(target.id));
return sendJson(res, 200, { ok: true, expiresAt: u ? u.expiresAt : unlock.expiresAt, channel: `#${targetName}` });
return sendJson(res, 200, { ok: true, expiresAt: verified.expiresAt, channel: `#${targetName}` });
}
/**
* Post on behalf of an unlock; answers the response. `targetId` is an
* already-validated configured channel; `presetChannel` (a quick reply's
* own "| #channel") overrides it.
*/
async function deliver(req, res, unlock, { text, targetId, presetChannel = '', 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' });
}
unlocks.get(unlock.token).lastSendAt = now;
/** Post to an already-validated configured channel; answers the response. */
async function deliver(req, res, verified, { text, targetId, what }) {
if (!identity || authError) {
return sendJson(res, 502, { error: authError || 'Slack token not checked yet — try again in a moment' });
}
let target = targetId || '';
if (presetChannel) {
try {
target = await resolveChannelRef(presetChannel);
} catch (err) {
return sendJson(res, 502, { error: describeError(err, { name: identity.name, channel: presetChannel }) });
}
if (!target) return sendJson(res, 400, { error: `No channel named #${String(presetChannel).replace(/^#/, '')} that ${identity.name} can see` });
}
if (!target) return sendJson(res, 400, { error: 'No channel configured — pick channels in Admin → Slack → Channels' });
const targetName = lookupChannel(target) || target;
const targetName = lookupChannel(targetId) || targetId;
try {
const data = await postMessage(target, text);
const u = unlocks.get(unlock.token);
if (u) u.expiresAt = Date.now() + idleMs;
const data = await postMessage(targetId, text);
const preview = text.replace(/\s+/g, ' ').slice(0, 60);
log(`${what} → #${targetName} as ${sendingAs()}: "${preview}${text.length > 60 ? '…' : ''}"`);
if (pollers.has(target)) pollSoon(pollers.get(target));
if (pollers.has(targetId)) pollSoon(pollers.get(targetId));
return sendJson(res, 200, {
ok: true,
ts: String(data.ts || ''),
label: `#${targetName}`,
channel: `#${targetName}`,
channelId: target,
channelId: targetId,
sendingAs: sendingAs(),
expiresAt: u ? u.expiresAt : unlock.expiresAt,
expiresAt: verified.expiresAt,
});
} catch (err) {
const message = describeError(err, { name: sendingAs() || identity.name, channel: targetName });
@@ -1290,12 +1164,9 @@ module.exports = {
}
}, HEARTBEAT_MS);
heartbeat.unref?.();
const sweeper = setInterval(sweepUnlocks, UNLOCK_SWEEP_MS);
sweeper.unref?.();
if (!token) log('no Slack token configured — paste one in /admin');
else {
if (sendEnabled && !pinOk) log('sending is on but no valid PIN (4–8 digits) is set — send tiles stay locked');
if (!channelIds.length) log('no channels configured — pick some in /admin → Slack → Channels');
else {
if (!Array.isArray(config.channels) || !config.channels.length) {
@@ -1311,8 +1182,8 @@ module.exports = {
streams,
snapshot,
health,
quickReplies,
sendGate,
shellPort,
messagingDescriptor,
deliver,
react,
/** Everything a tile needs to draw emoji: the shipped table plus the workspace's own. */
@@ -1331,7 +1202,6 @@ module.exports = {
if (kind === 'b') return botIcons.get(id) || '';
return '';
},
unlockFor,
chooseTarget,
namesReady,
touch() { lastInterest = Date.now(); },
@@ -1347,44 +1217,8 @@ module.exports = {
loadChannelList,
channelIds,
channelName: (id) => lookupChannel(id),
pinOk,
sendEnabled,
identityName: () => identity?.name || '',
sendingAs,
unlock(req, res, given) {
if (!sendEnabled) return sendJson(res, 403, { error: 'Sending is off — turn it on in Admin → Slack → Sending', code: 'sending_off' });
if (!pinOk) return sendJson(res, 403, { error: 'Set a PIN in Admin → Slack → Sending', code: 'no_pin' });
const ip = clientIp(req);
const now = Date.now();
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, sendingAs: sendingAs() });
},
lock(req, res) {
const t = bearer(req);
if (t) unlocks.delete(t);
return sendJson(res, 200, { ok: true });
},
};
return {
@@ -1398,8 +1232,6 @@ module.exports = {
}
}
clearInterval(heartbeat);
clearInterval(sweeper);
unlocks.clear(); // an admin Apply locks every send tile
for (const res of streams) {
try { res.end(); } catch { /* already gone */ }
}
@@ -1412,16 +1244,17 @@ module.exports = {
/**
* The picker: one transcript per configured channel, titled with the
* channel's name (the shell makes the entry's name the tile's title), then
* the two send surfaces, one of each per dashboard. Between init() and a
* poller's first answer a name may still be unknown — "#<id>" then.
* channel's name (the shell makes the entry's name the tile's title).
* Between init() and a poller's first answer a name may still be unknown
* — "#<id>" then. The send surfaces that used to follow here are the Send
* Message module's since 1.3.0; a layout's old Send message / Quick
* replies tiles draw nothing until they are removed.
*/
async tiles({ config }) {
const c = current;
const ids = c ? c.channelIds : configuredChannels(config || {});
if (c) await c.namesReady();
const who = c?.sendingAs() ? ` as ${c.sendingAs()}` : '';
const entries = ids.map((id) => ({
return ids.map((id) => ({
id: `transcript:${id}`,
name: `#${(c && c.channelName(id)) || id}`,
description: 'Live transcript',
@@ -1429,27 +1262,6 @@ module.exports = {
minSize: { w: 2, h: 2 },
settings: { view: 'transcript', channel: id },
}));
entries.push(
{
id: 'send',
name: 'Send message',
description: `PIN-protected message to the transcript clicked last${who}`,
single: true,
defaultSize: { w: 3, h: 3 },
minSize: { w: 2, h: 2 },
settings: { view: 'send' },
},
{
id: 'quick',
name: 'Quick replies',
description: `PIN-protected preset buttons${who}`,
single: true,
defaultSize: { w: 3, h: 2 },
minSize: { w: 2, h: 1 },
settings: { view: 'quick' },
},
);
return entries;
},
routes() {
@@ -1498,22 +1310,6 @@ module.exports = {
}
}),
'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);
},
// The emoji a tile may need to draw — fetched once per page, again when the version bumps.
'GET /emoji': (req, res) => {
if (!current) return unmounted(res);
@@ -1530,35 +1326,19 @@ module.exports = {
await serveImage(res, current.avatarUrl(decodeURIComponent(String(req.wildcard || '').replace(/^\/+/, ''))));
}),
// A reaction as the location — PIN-gated like a send.
'POST /react': guard(async (req, res) => {
// The Send Message module reads this over loopback: where it may send.
'GET /messaging': (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 });
}
await current.react(req, res, unlock, { channel: body.channel, ts: body.ts, name: body.name, remove: Boolean(body.remove) });
}),
// Labels and indexes 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, channel: q.channel ? `#${q.channel}` : '' })),
expiresAt: unlock.expiresAt,
});
sendJson(res, 200, current.messagingDescriptor());
},
'POST /send': guard(async (req, res) => {
// …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 is posted.
'POST /messaging/send': guard(async (req, res) => {
if (!current) return unmounted(res);
const unlock = current.sendGate(req, res);
if (!unlock) return;
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);
@@ -1568,34 +1348,23 @@ module.exports = {
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 target = current.chooseTarget(body.channel);
if (target.error) return sendJson(res, 400, { error: target.error, code: 'bad_channel' });
await current.deliver(req, res, unlock, { text, targetId: target.id, what: 'sent' });
const target = current.chooseTarget(body.destination ?? body.channel);
if (target.error) return sendJson(res, 400, { error: target.error, code: 'bad_destination' });
await current.deliver(req, res, v, { text, targetId: target.id, what: 'sent' });
}),
'POST /quick': guard(async (req, res) => {
// A reaction as the location — the same unlock as a send.
'POST /react': guard(async (req, res) => {
if (!current) return unmounted(res);
const unlock = current.sendGate(req, res);
if (!unlock) return;
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 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 channel the browser names is checked even when the preset's own
// binding is about to override it — an unconfigured id is never accepted.
const target = current.chooseTarget(body.channel);
if (target.error && (body.channel || !preset.channel)) return sendJson(res, 400, { error: target.error, code: 'bad_channel' });
await current.deliver(req, res, unlock, {
text: preset.message,
targetId: target.id || '',
presetChannel: preset.channel,
what: `quick reply "${preset.label}"`,
});
await current.react(req, res, v, { channel: body.channel, ts: body.ts, name: body.name, remove: Boolean(body.remove) });
}),
};
},