Files
ProdDash/modules/slack/server.js
T
Waters ChurchandClaude Fable 5.1 babe8263eb Slack module: channel transcript + PIN-gated sending as the location
Each campus signs its booth machine into Slack as the account named for
the location, so a message from ProdDash reads as "Apollo Beach" rather
than as whoever is at the desk. modules/slack (1.0.0) is three tiles from
one module: the live transcript of one channel, a free-text Send tile and
a Quick-replies grid — the last two behind a PIN pad, auto-locking after
a configurable idle time.

Server: one user (xoxp-) or bot (xoxb-) token, identified with auth.test;
conversations.history polled every N seconds (thread replies via
conversations.replies, capped per poll), diffed and fanned out as one
`state` SSE snapshot; mrkdwn rendered to safe text on the server; sends
through chat.postMessage (username/icon for a bot with "Send as"); PIN
compared in constant time with a 5-wrong → 30 s lockout, 32-byte unlock
tokens with a sliding expiry, 1 message/s, every send logged; readable
messages for invalid_auth / token_revoked / missing_scope /
not_in_channel / ratelimited; health reflects token → channel → poll →
sending. SLACK_API_BASE points it at tools/slack-mock.js (new).

Shell: the admin config form gains a multi-line field type `text` (a
textarea; one item per line by convention) — used for the quick replies.
Documented in the module guide; the mock is listed in CLAUDE.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 11:49:40 -04:00

1008 lines
41 KiB
JavaScript
Raw 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';
/**
* Slack — server part.
*
* One Slack Web API connection per ProdDash machine — the campus's shared
* location account (a user token), or a bot — polled for the configured
* channel's recent history and fanned 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.
*
* GET /state snapshot: identity, channel, messages, sending state
* GET /stream SSE `state` events (change-detected, heartbeated)
* GET /channels admin select 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 /quick Bearer, { index } → post that preset
*
* SLACK_API_BASE=http://127.0.0.1:24716/api points the module at
* 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(/\/+$/, '');
/** Abort a hung Slack request well before the poll cadence stacks up. */
const REQUEST_TIMEOUT_MS = 10000;
/** SSE comment ping cadence — keeps idle connections alive through sleepy Wi-Fi. */
const HEARTBEAT_MS = 15000;
/** Fastest allowed history poll; the admin default is 2 s. */
const MIN_POLL_S = 1;
/** With no tile watching, poll this slowly instead — the API budget is shared with sends. */
const IDLE_POLL_S = 15;
/** …once nothing has asked for /state or /stream for this long. */
const IDLE_AFTER_MS = 30000;
const HISTORY_MIN = 5;
const HISTORY_MAX = 200;
/** Thread fetches per poll (oldest parent first) — keeps a busy channel inside Tier 3. */
const THREAD_FETCHES_PER_POLL = 3;
const THREAD_REPLIES_LIMIT = 50;
const USER_LOOKUPS_PER_POLL = 15;
const USER_TTL_MS = 60 * 60 * 1000;
const CHANNEL_LIST_TTL_MS = 10 * 60 * 1000;
const RETRY_NETWORK_MS = 10000;
/** A bad token or an unreadable channel: nothing we do fixes it, so re-check slowly. */
const RETRY_CONFIG_MS = 60000;
/** "Join it in Slack" — someone may just have. */
const RETRY_MEMBERSHIP_MS = 30000;
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;
/** Set by init(), read by routes() and tiles() — all rebuilt together on remount. */
let current = null;
/* ── Slack Web API ──────────────────────────────────────────────────── */
class SlackError extends Error {
constructor(code, extra = {}) {
super(code);
this.code = code;
Object.assign(this, extra);
}
}
/** Call one Web API method. Form-encoded by default; chat.postMessage takes JSON. */
async function slackCall(token, method, params = {}, { json = false } = {}) {
const headers = { Authorization: `Bearer ${token}`, Accept: 'application/json' };
let body;
if (json) {
headers['Content-Type'] = 'application/json; charset=utf-8';
body = JSON.stringify(params);
} else {
headers['Content-Type'] = 'application/x-www-form-urlencoded';
const form = new URLSearchParams();
for (const [k, v] of Object.entries(params)) {
if (v !== undefined && v !== null && v !== '') form.set(k, String(v));
}
body = form.toString();
}
let response;
try {
response = await fetch(`${API_BASE}/${method}`, {
method: 'POST',
headers,
body,
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
} catch (err) {
throw new SlackError('network', { network: true, detail: err?.message || String(err) });
}
if (response.status === 429) {
const retryAfter = Math.max(1, Math.trunc(Number(response.headers.get('retry-after'))) || 5);
throw new SlackError('ratelimited', { retryAfter, status: 429 });
}
let data;
try {
data = await response.json();
} catch {
throw new SlackError('bad_response', { status: response.status });
}
if (!response.ok || !data || data.ok !== true) {
const meta = data?.response_metadata;
throw new SlackError(String(data?.error || `http_${response.status}`), {
status: response.status,
needed: data?.needed ? String(data.needed) : '',
detail: Array.isArray(meta?.messages) ? meta.messages.join('; ') : '',
});
}
return data;
}
/** A Slack error as a sentence a tech can act on. */
function describeError(err, ctx = {}) {
const who = ctx.name || 'The Slack account';
const chan = ctx.channel ? `#${ctx.channel}` : 'the channel';
switch (err?.code) {
case 'network': return `Slack unreachable: ${err.detail}`;
case 'ratelimited': return `Slack is rate-limiting — try again in ${err.retryAfter} s`;
case 'invalid_auth': return 'Slack rejected the token (invalid_auth) — paste a current User OAuth Token in Admin → Slack';
case 'not_authed': return 'No token reached Slack (not_authed) — paste the User OAuth Token in Admin → Slack';
case 'token_revoked': return 'The token was revoked (token_revoked) — Reinstall to Workspace in the Slack app and paste the new token';
case 'token_expired': return 'The token expired (token_expired) — Reinstall to Workspace in the Slack app and paste the new token';
case 'account_inactive': return 'The Slack account behind the token is deactivated (account_inactive)';
case 'missing_scope': return `The token is missing the ${err.needed || 'required'} scope (missing_scope) — add it under OAuth & Permissions, reinstall, paste the new token`;
case 'not_in_channel': return `${who} isn't a member of ${chan} — join it in Slack`;
case 'channel_not_found': return 'Channel not found (channel_not_found) — pick it again in Admin → Slack → Channel';
case 'is_archived': return `${chan} is archived (is_archived)`;
case 'msg_too_long': return 'Message too long for Slack (msg_too_long)';
case 'no_text': return 'Nothing to send (no_text)';
case 'restricted_action': return `Posting in ${chan} is restricted by a workspace admin (restricted_action)`;
case 'bad_response': return `Slack answered with something that isn't JSON (HTTP ${err.status})`;
default: return `Slack error: ${err?.code || err?.message || err}${err?.detail ? ` — ${err.detail}` : ''}`;
}
}
/** Errors that no amount of retrying fixes — config has to change. */
function isConfigError(err) {
return ['invalid_auth', 'not_authed', 'token_revoked', 'token_expired', 'account_inactive', 'missing_scope', 'channel_not_found'].includes(err?.code);
}
/* ── mrkdwn → plain text with a few inline marks ────────────────────── */
const EMOJI = {
'+1': '👍', thumbsup: '👍', '-1': '👎', thumbsdown: '👎', white_check_mark: '✅', heavy_check_mark: '✔️',
x: '❌', pray: '🙏', tada: '🎉', fire: '🔥', eyes: '👀', heart: '❤️', clap: '👏', warning: '⚠️',
rotating_light: '🚨', smile: '😄', grinning: '😀', joy: '😂', sweat_smile: '😅', thinking_face: '🤔',
wave: '👋', ok_hand: '👌', raised_hands: '🙌', bell: '🔔', microphone: '🎤', musical_note: '🎵',
video_camera: '📹', tv: '📺', church: '⛪', zap: '⚡', 100: '💯', point_right: '👉', muscle: '💪',
slightly_smiling_face: '🙂', raised_hand: '✋', satellite_antenna: '📡',
};
function unescapeSlack(s) {
return s.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
}
/**
* Slack mrkdwn → plain text. Mentions, channel links, broadcasts and URLs are
* spelled out; *bold* _italic_ ~strike~ `code` markers are kept for the
* client to style; :shortcodes: in the small map become the emoji.
*/
function renderMrkdwn(raw, lookupUser, lookupChannel) {
let text = String(raw || '');
text = text.replace(/<([^<>]+)>/g, (m, inner) => {
if (inner.startsWith('@')) {
const [id, label] = inner.slice(1).split('|');
return '@' + (label || lookupUser(id) || id);
}
if (inner.startsWith('#')) {
const [id, label] = inner.slice(1).split('|');
return '#' + (label || lookupChannel(id) || id);
}
if (inner.startsWith('!')) {
const [cmd, label] = inner.slice(1).split('|');
if (cmd.startsWith('subteam^')) return label ? (label.startsWith('@') ? label : '@' + label) : '@group';
if (cmd.startsWith('date^')) {
const secs = Number(cmd.split('^')[1]);
return label || (secs ? new Date(secs * 1000).toLocaleString() : 'date');
}
return '@' + (label || cmd);
}
const [url, label] = inner.split('|');
if (label && label !== url) return `${label} (${url})`;
return url.replace(/^mailto:/, '');
});
text = unescapeSlack(text);
text = text.replace(/:([a-z0-9_+-]+):/g, (m, code) => EMOJI[code] || m);
return text;
}
/** Text from a blocks-only message (section / context / rich_text). */
function textFromBlocks(blocks) {
const out = [];
const walk = (node) => {
if (!node || typeof node !== 'object') return;
if (Array.isArray(node)) return void node.forEach(walk);
if (typeof node.text === 'string' && (node.type === 'text' || node.type === 'mrkdwn' || node.type === 'plain_text')) {
out.push(node.text);
return;
}
if (node.type === 'link') return void out.push(node.text || node.url || '');
if (node.type === 'emoji' && node.name) return void out.push(`:${node.name}:`);
if (node.type === 'user' && node.user_id) return void out.push(`<@${node.user_id}>`);
if (node.type === 'channel' && node.channel_id) return void out.push(`<#${node.channel_id}>`);
if (node.type === 'broadcast' && node.range) return void out.push(`<!${node.range}>`);
for (const key of ['text', 'elements', 'fields', 'title']) if (node[key]) walk(node[key]);
};
walk(blocks);
return out.join(' ').replace(/\s+\n/g, '\n').trim();
}
/* ── 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;
}
/**
* "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.
*/
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 });
}
return out;
}
const SYSTEM_SUBTYPES = new Set([
'channel_join', 'channel_leave', 'channel_topic', 'channel_purpose', 'channel_name',
'channel_archive', 'channel_unarchive', 'group_join', 'group_leave', 'group_topic',
'group_purpose', 'group_name', 'pinned_item', 'unpinned_item', 'bot_add', 'bot_remove',
'sh_room_created', 'reminder_add', 'huddle_thread',
]);
/* ── the module ─────────────────────────────────────────────────────── */
module.exports = {
init({ config, log }) {
/* ── config ── */
const token = String(config.token || '').trim();
const sendAs = String(config.sendAs || '').trim();
const channelId = String(config.channel || '').trim();
const pollMs = Math.max(MIN_POLL_S, Number(config.pollSeconds) || 2) * 1000;
const historyCount = Math.min(HISTORY_MAX, Math.max(HISTORY_MIN, Math.trunc(Number(config.historyCount)) || 40));
const includeReplies = Boolean(config.includeReplies);
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);
/* ── state (all inside this closure so a re-init starts clean) ── */
let stopped = false;
let identity = null; // { kind: 'user'|'bot', name, handle, userId, team, botId }
let authError = '';
let channel = null; // { id, name, isMember, isPrivate }
let channelError = '';
let pollError = '';
let lastPollAt = 0;
let rawWindow = []; // conversations.history messages, as Slack sent them (newest first)
const threads = new Map(); // parent ts -> { latestReply, replyCount, replies: [raw…] }
const users = new Map(); // user id -> { name, at }
const pendingUsers = new Set();
const channelNames = new Map(); // channel id -> name (from list/info calls)
let channelList = { at: 0, channels: [] };
let channelListPromise = null;
let snapshotCache = null;
let lastSig = '';
let lastInterest = Date.now();
let pollTimer = null;
let polling = false;
let pollAgain = false;
let currentIntervalMs = pollMs;
const streams = new Set();
const unlocks = new Map(); // token -> { expiresAt, lastSendAt, ip }
const pinAttempts = new Map(); // ip -> { count, lockedUntil }
/* ── names ── */
function lookupUser(id) {
if (!id) return '';
const hit = users.get(id);
if (hit && Date.now() - hit.at < USER_TTL_MS) return hit.name;
pendingUsers.add(id);
return hit ? hit.name : '';
}
function lookupChannel(id) {
return channelNames.get(id) || '';
}
async function resolveUsers() {
const ids = [...pendingUsers].slice(0, USER_LOOKUPS_PER_POLL);
if (!ids.length) return false;
let changed = false;
await Promise.all(ids.map(async (id) => {
pendingUsers.delete(id);
try {
const data = await slackCall(token, 'users.info', { user: id });
const u = data.user || {};
const name = String(u.profile?.display_name || u.real_name || u.profile?.real_name || u.name || id);
users.set(id, { name, at: Date.now() });
changed = true;
} catch (err) {
// Unknown or deactivated: remember the raw id for a while so we
// don't ask again every poll. Rate limits and outages retry.
if (err.code !== 'network' && err.code !== 'ratelimited') users.set(id, { name: id, at: Date.now() });
}
}));
return changed;
}
/* ── channel list (admin options, #name → id, <#C…> rendering) ── */
async function loadChannelList(force = false) {
if (!force && Date.now() - channelList.at < CHANNEL_LIST_TTL_MS) return channelList.channels;
if (channelListPromise) return channelListPromise;
channelListPromise = (async () => {
const all = [];
let cursor = '';
for (let page = 0; page < 10; page += 1) {
const data = await slackCall(token, 'conversations.list', {
types: 'public_channel,private_channel',
exclude_archived: 'true',
limit: 200,
cursor: cursor || undefined,
});
for (const c of data.channels || []) {
if (!c?.id) continue;
channelNames.set(String(c.id), String(c.name || ''));
all.push({ id: String(c.id), name: String(c.name || ''), isMember: Boolean(c.is_member), isPrivate: Boolean(c.is_private) });
}
cursor = String(data.response_metadata?.next_cursor || '');
if (!cursor) break;
}
all.sort((a, b) => a.name.localeCompare(b.name));
channelList = { at: Date.now(), channels: all };
return all;
})();
try {
return await channelListPromise;
} finally {
channelListPromise = null;
}
}
/** "#name" or a channel id → the channel id, or '' when the account can't see it. */
async function resolveChannel(ref) {
const s = String(ref || '').trim();
if (!s) return channelId;
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);
if (m.user) {
const n = lookupUser(String(m.user));
if (n) return n;
}
if (m.bot_profile?.name) return String(m.bot_profile.name);
if (m.username) return String(m.username);
return String(m.user || m.bot_id || 'unknown');
}
function textFor(m) {
let text = renderMrkdwn(m.text, lookupUser, lookupChannel).trim();
if (!text && Array.isArray(m.attachments)) {
text = m.attachments
.map((a) => renderMrkdwn(a?.fallback || a?.text || a?.title || '', lookupUser, lookupChannel).trim())
.filter(Boolean)
.join('\n');
}
if (!text && Array.isArray(m.blocks)) text = renderMrkdwn(textFromBlocks(m.blocks), lookupUser, lookupChannel);
if (Array.isArray(m.files) && m.files.length) {
const files = m.files.map((f) => `[file: ${f?.title || f?.name || 'file'}]`).join(' ');
text = text ? `${text}\n${files}` : files;
}
return text;
}
function normalize(m, parentTs = '') {
if (!m || typeof m !== 'object' || !m.ts) return null;
if (m.subtype === 'tombstone') return null;
const name = nameFor(m);
const system = SYSTEM_SUBTYPES.has(m.subtype || '');
let text = textFor(m);
if (m.subtype === 'channel_join' || m.subtype === 'group_join') text = `${name} joined ${channel ? '#' + channel.name : 'the channel'}`;
else if (m.subtype === 'channel_leave' || m.subtype === 'group_leave') text = `${name} left ${channel ? '#' + channel.name : 'the channel'}`;
const out = {
id: String(m.ts),
ts: String(m.ts),
time: Math.round(Number(m.ts) * 1000) || Date.now(),
name,
text,
edited: Boolean(m.edited),
system,
subtype: String(m.subtype || ''),
bot: Boolean(m.bot_id) || m.subtype === 'bot_message',
me: Boolean(identity && m.user && m.user === identity.userId),
};
if (parentTs) out.parent = parentTs;
else {
out.replyCount = Math.max(0, Math.trunc(Number(m.reply_count)) || 0);
if (includeReplies && out.replyCount) {
const t = threads.get(out.ts);
out.replies = t ? t.replies.map((r) => normalize(r, out.ts)).filter(Boolean) : [];
}
}
return out;
}
function identityLabel() {
if (!identity) return '';
if (identity.kind === 'bot') return sendAs ? `${identity.name} (bot) as "${sendAs}"` : `${identity.name} (bot)`;
return identity.name;
}
function sendingAs() {
if (!identity) return '';
return identity.kind === 'bot' && sendAs ? sendAs : identity.name;
}
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 };
if (!identity) return { status: 'connecting', message: 'Checking the Slack token…' };
const who = identityLabel();
if (!channelId) return { status: 'error', message: `${who} · pick a channel in Admin → Slack → Channel` };
if (channelError) return { status: 'error', message: `${who} · ${channelError}` };
if (!channel) return { status: 'connecting', message: `${who} · looking up the channel…` };
const sending = !sendEnabled ? 'sending off' : !pinOk ? 'sending on — set a 4–8 digit PIN' : 'sending on';
if (pollError) return { status: 'error', message: `${who} · #${channel.name} · ${pollError}` };
if (!lastPollAt) return { status: 'connecting', message: `${who} · #${channel.name} · first read…` };
const age = Date.now() - lastPollAt;
if (age > 3 * currentIntervalMs + 5000) {
return { status: 'connecting', message: `${who} · #${channel.name} · last read ${Math.round(age / 1000)} s ago` };
}
return { status: 'ok', message: `${who} · #${channel.name} · ${sending}` };
}
function snapshot() {
if (snapshotCache) return snapshotCache;
const messages = rawWindow
.map((m) => normalize(m))
.filter(Boolean)
.sort((a, b) => Number(a.ts) - Number(b.ts));
const h = health();
snapshotCache = {
identity: identity
? { kind: identity.kind, name: identity.name, team: identity.team, sendAs: identity.kind === 'bot' ? sendAs : '' }
: null,
sendingAs: sendingAs(),
channel: channel ? { id: channel.id, name: channel.name, isMember: channel.isMember } : (channelId ? { id: channelId, name: lookupChannel(channelId), isMember: null } : null),
messages,
includeReplies,
historyCount,
sending: { enabled: sendEnabled, pinSet: pinOk, idleMinutes: idleMs / 60000 },
status: { state: h.status, message: h.message, lastPollAt },
apiBase: API_BASE,
};
return snapshotCache;
}
function invalidate() {
snapshotCache = null;
}
/** Broadcast the snapshot when anything a tile shows has changed. */
function broadcastIfChanged() {
invalidate();
const snap = snapshot();
const { lastPollAt: _ignored, ...statusSig } = snap.status;
const sig = JSON.stringify({ ...snap, status: statusSig });
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 poll loop: token → channel → history (+ threads, names) ── */
function schedule(ms) {
if (stopped) return;
clearTimeout(pollTimer);
currentIntervalMs = ms;
pollTimer = setTimeout(tick, ms);
pollTimer.unref?.();
}
function pollSoon() {
if (stopped) return;
if (polling) {
pollAgain = true;
return;
}
clearTimeout(pollTimer);
pollTimer = setTimeout(tick, 250);
pollTimer.unref?.();
}
function watched() {
return streams.size > 0 || Date.now() - lastInterest < IDLE_AFTER_MS;
}
async function checkAuth() {
const data = await slackCall(token, 'auth.test', {});
const userId = String(data.user_id || '');
const id = {
kind: data.bot_id ? 'bot' : 'user',
name: String(data.user || 'Slack'),
handle: String(data.user || ''),
userId,
team: String(data.team || ''),
botId: data.bot_id ? String(data.bot_id) : '',
};
// The handle is "apollo.beach"; the display name is "Apollo Beach".
if (userId) {
try {
const u = await slackCall(token, 'users.info', { user: userId });
const name = String(u.user?.profile?.display_name || u.user?.real_name || u.user?.profile?.real_name || '').trim();
if (name) id.name = name;
users.set(userId, { name: id.name, at: Date.now() });
} catch { /* the handle will do */ }
}
identity = id;
authError = '';
log(`token belongs to ${identityLabel()} (${id.team || 'team unknown'})${id.kind === 'bot' && !sendAs ? ' — messages will read as the app; set "Send as" to name the location' : ''}`);
}
async function checkChannel() {
const data = await slackCall(token, 'conversations.info', { channel: channelId });
const c = data.channel || {};
channel = {
id: String(c.id || channelId),
name: String(c.name || c.name_normalized || channelId),
isMember: c.is_member !== false,
isPrivate: Boolean(c.is_private),
};
channelNames.set(channel.id, channel.name);
channelError = '';
if (!channel.isMember) {
channelError = `${identity?.name || 'The account'} isn't a member of #${channel.name} — join it in Slack`;
throw new SlackError('not_in_channel');
}
}
async function readHistory() {
const data = await slackCall(token, 'conversations.history', { channel: channelId, limit: historyCount });
const messages = Array.isArray(data.messages) ? data.messages.filter((m) => m && m.ts) : [];
rawWindow = messages;
lastPollAt = Date.now();
pollError = '';
// Threads: parents in the window whose reply set changed since we
// last read it — oldest first, a few per poll.
const inWindow = new Set(messages.map((m) => String(m.ts)));
for (const ts of [...threads.keys()]) if (!inWindow.has(ts)) threads.delete(ts);
if (includeReplies) {
const stale = messages
.filter((m) => Number(m.reply_count) > 0)
.filter((m) => {
const t = threads.get(String(m.ts));
return !t || t.latestReply !== String(m.latest_reply || '') || t.replyCount !== Number(m.reply_count);
})
.sort((a, b) => Number(a.ts) - Number(b.ts))
.slice(0, THREAD_FETCHES_PER_POLL);
for (const parent of stale) {
try {
const rep = await slackCall(token, 'conversations.replies', { channel: channelId, ts: parent.ts, limit: THREAD_REPLIES_LIMIT });
const replies = (rep.messages || []).filter((r) => r && r.ts && String(r.ts) !== String(parent.ts));
threads.set(String(parent.ts), { latestReply: String(parent.latest_reply || ''), replyCount: Number(parent.reply_count), replies });
} catch (err) {
if (err.code === 'ratelimited') throw err;
log(`thread ${parent.ts}: ${describeError(err, { name: identity?.name, channel: channel?.name })}`);
}
}
}
}
async function tick() {
if (stopped || polling) return;
polling = true;
pollAgain = false;
let next = watched() ? pollMs : Math.max(pollMs, IDLE_POLL_S * 1000);
try {
if (!identity) await checkAuth();
if (!channelId) {
next = RETRY_CONFIG_MS;
} else {
if (!channel || channelError) await checkChannel();
await readHistory();
await resolveUsers();
}
} catch (err) {
const ctx = { name: identity?.name, channel: channel?.name };
const text = describeError(err, ctx);
if (!identity) {
authError = text;
next = isConfigError(err) ? RETRY_CONFIG_MS : RETRY_NETWORK_MS;
} else if (!channel || channelError) {
if (!channelError) channelError = text;
next = err.code === 'not_in_channel' ? RETRY_MEMBERSHIP_MS : isConfigError(err) ? RETRY_CONFIG_MS : RETRY_NETWORK_MS;
} else {
pollError = text;
if (err.code === 'not_in_channel') {
channel.isMember = false;
channelError = text;
next = RETRY_MEMBERSHIP_MS;
} else next = isConfigError(err) ? RETRY_CONFIG_MS : Math.max(pollMs, RETRY_NETWORK_MS);
}
if (err.code === 'ratelimited') next = err.retryAfter * 1000;
log(text);
} finally {
polling = false;
broadcastIfChanged();
if (pollAgain) pollSoon();
else schedule(next);
}
}
/* ── 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;
}
async function postMessage(targetChannelId, text) {
const params = { channel: targetChannelId, text };
if (identity?.kind === 'bot' && sendAs) {
params.username = sendAs;
params.icon_emoji = DEFAULT_ICON;
}
return slackCall(token, 'chat.postMessage', params, { json: true });
}
/** Post on behalf of an unlock; answers the response. */
async function deliver(req, res, unlock, { text, channelRef, 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;
if (!identity || authError) {
return sendJson(res, 502, { error: authError || 'Slack token not checked yet — try again in a moment' });
}
let target = channelId;
if (channelRef) {
try {
target = await resolveChannel(channelRef);
} catch (err) {
return sendJson(res, 502, { error: describeError(err, { name: identity.name, channel: channel?.name }) });
}
if (!target) return sendJson(res, 400, { error: `No channel named #${String(channelRef).replace(/^#/, '')} that ${identity.name} can see` });
}
if (!target) return sendJson(res, 400, { error: 'No channel — pick one in Admin → Slack → Channel' });
const targetName = lookupChannel(target) || target;
try {
const data = await postMessage(target, text);
const u = unlocks.get(unlock.token);
if (u) u.expiresAt = Date.now() + idleMs;
const preview = text.replace(/\s+/g, ' ').slice(0, 60);
log(`${what} → #${targetName} as ${sendingAs()}: "${preview}${text.length > 60 ? '…' : ''}"`);
if (target === channelId) pollSoon();
return sendJson(res, 200, {
ok: true,
ts: String(data.ts || ''),
channel: `#${targetName}`,
sendingAs: sendingAs(),
expiresAt: u ? u.expiresAt : unlock.expiresAt,
});
} catch (err) {
const message = describeError(err, { name: sendingAs() || identity.name, channel: targetName });
log(`${what} to #${targetName} failed: ${message}`);
return sendJson(res, err.code === 'ratelimited' ? 429 : 502, { error: message, code: err.code || 'slack_error' });
}
}
/* ── 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 (!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');
schedule(50);
}
current = {
streams,
snapshot,
health,
quickReplies,
sendGate,
deliver,
unlockFor,
touch() { lastInterest = Date.now(); },
wake() {
lastInterest = Date.now();
if (identity && channel && Date.now() - lastPollAt > pollMs) pollSoon();
},
loadChannelList,
channelId,
pinOk,
sendEnabled,
identityName: () => identity?.name || '',
channelName: () => channel?.name || '',
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(), channel: channel ? `#${channel.name}` : '' });
},
lock(req, res) {
const t = bearer(req);
if (t) unlocks.delete(t);
return sendJson(res, 200, { ok: true });
},
};
return {
stop() {
stopped = true;
clearTimeout(pollTimer);
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,
};
},
tiles() {
const snap = current ? current.snapshot() : null;
const chan = snap?.channel?.name ? `#${snap.channel.name}` : 'the configured channel';
const who = snap?.sendingAs ? ` as ${snap.sendingAs}` : '';
return [
{ id: 'transcript', name: 'Transcript', description: `Live transcript of ${chan}`, defaultSize: { w: 4, h: 4 }, minSize: { w: 2, h: 2 }, settings: { view: 'transcript' } },
{ id: 'send', name: 'Send message', description: `PIN-protected message to ${chan}${who}`, defaultSize: { w: 3, h: 3 }, minSize: { w: 2, h: 2 }, settings: { view: 'send' } },
{ id: 'quick', name: 'Quick replies', description: `PIN-protected preset buttons${who}`, defaultSize: { w: 3, h: 2 }, minSize: { w: 2, h: 1 }, settings: { view: 'quick' } },
];
},
routes() {
const unmounted = (res) => sendJson(res, 503, { error: 'Slack 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({ messages: [], status: { state: 'connecting', message: 'Slack module is restarting…' } })}\n\n`);
return void res.end();
}
res.write(`event: state\ndata: ${JSON.stringify(current.snapshot())}\n\n`);
// A viewer that stops reading (locked tablet, sleeping laptop) must
// not queue frames forever; keepalive probes make the OS notice.
try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
current.streams.add(res);
current.wake();
req.on('close', () => current && current.streams.delete(res));
},
// The admin "Channel" select: channels the account is a member of.
'GET /channels': guard(async (req, res) => {
if (!current) return unmounted(res);
try {
const list = await current.loadChannelList(true);
const options = list
.filter((c) => c.isMember)
.map((c) => ({ value: c.id, label: `#${c.name}${c.isPrivate ? ' (private)' : ''}` }));
sendJson(res, 200, { options });
} catch (err) {
// 200 with no options: the admin page keeps the saved value selectable.
sendJson(res, 200, { options: [], error: describeError(err, { name: current.identityName() }) });
}
}),
'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);
},
// 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,
});
},
'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' });
if (text.length > MAX_TEXT_CHARS) return sendJson(res, 400, { error: `Too long — ${MAX_TEXT_CHARS} characters at most` });
await current.deliver(req, res, unlock, { text, channelRef: body.channel ? String(body.channel) : '', 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' });
await current.deliver(req, res, unlock, { text: preset.message, channelRef: preset.channel, what: `quick reply "${preset.label}"` });
}),
};
},
};