'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}"` }); }), }; }, };