'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 — 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. Browsers never * talk to Slack; the token stays here. * * 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/, GET /avatar/ images proxied from Slack's CDN * * `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 * HISTORY_BUDGET_PER_MIN (40) so conversations.replies, users.info and sends * have room: * * interval per channel = max(pollSeconds, ceil(channels × 60 / 40)) seconds * * 1 channel → max(2, ceil(1.5)) = 2 s (30 history calls/min) * 2 channels → max(2, ceil(3)) = 3 s (40/min) * 3 channels → max(2, ceil(4.5)) = 5 s (36/min) * 5 channels → max(2, ceil(7.5)) = 8 s (37.5/min) * * Pollers are phase-staggered across that interval so they never fire in a * burst; with no tile watching, every channel drops to IDLE_POLL_S. * * SLACK_API_BASE=http://127.0.0.1:24716/api points the module at * tools/slack-mock.js for development. */ 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; /** The scopes on the token, as Slack's x-oauth-scopes header names them on every answer. */ let tokenScopes = ''; /** Scopes a feature needs beyond reading: what to tell the tech when one is missing. */ const OPTIONAL_SCOPES = [['reactions:write', 'emoji reactions'], ['emoji:read', 'custom emoji']]; /** 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; /** * conversations.history calls per minute the whole module allows itself. * Slack gives an internal app's token roughly 50+ a minute (Tier 3); the * rest is headroom for conversations.replies, users.info and sends. The * per-channel interval follows from it — see pollIntervalMs(). */ const HISTORY_BUDGET_PER_MIN = 40; /** 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 across the module, split between channels (at least one each). */ 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; /** The first read of each channel right after boot, one after another (then the phase-locked cadence). */ const FIRST_READ_MS = 50; const FIRST_READ_STAGGER_MS = 250; /** A slot that has (nearly) passed is skipped rather than fired late. */ 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 MAX_TEXT_CHARS = 4000; const BODY_LIMIT_BYTES = 64 * 1024; const DEFAULT_ICON = ':satellite_antenna:'; /** 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; /* ── config ─────────────────────────────────────────────────────────── */ /** * The configured channel ids, in admin order. `channels` is the multiselect * (an array); a `channel` string is what 1.0.0 saved and is honoured as a * one-item list until the admin saves the new field — the module never * rewrites admin config itself. */ function configuredChannels(config) { const out = []; const add = (v) => { const s = String(v ?? '').trim(); if (s && !out.includes(s)) out.push(s); }; if (Array.isArray(config?.channels) && config.channels.length) config.channels.forEach(add); else add(config?.channel); return out; } /** * Effective per-channel poll interval, in ms: * * max(pollSeconds, ceil(channels × 60 / HISTORY_BUDGET_PER_MIN)) seconds * * so all channels together stay at or under the budget. With the default * 2 s: 1 channel → 2 s, 2 → 3 s, 3 → 5 s, 4 → 6 s, 5 → 8 s, 10 → 15 s. */ function pollIntervalMs(pollSeconds, channelCount) { const budgeted = Math.ceil((Math.max(1, channelCount) * 60) / HISTORY_BUDGET_PER_MIN); return Math.max(MIN_POLL_S, pollSeconds, budgeted) * 1000; } /* ── 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) }); } const scopes = response.headers.get('x-oauth-scopes'); if (scopes) tokenScopes = scopes; 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 `${chan} was not found (channel_not_found) — pick it again in Admin → Slack → Channels`; 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 'invalid_name': return 'Slack has no emoji by that name (invalid_name)'; case 'too_many_reactions': return 'Slack allows 23 different reactions on a message (too_many_reactions)'; case 'too_many_emoji': return 'That message already carries as many reactions as Slack allows (too_many_emoji)'; case 'message_not_found': return 'That message is gone (message_not_found)'; 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 ────────────────────── */ /* ── emoji: Slack's :codes: → characters; the workspace's custom set; images ── */ /** Slack's short names → emoji — modules/slack/emoji.json, built from iamcal/emoji-data (MIT). */ const EMOJI_NAMES = require('./emoji.json').names; /** One code as it sits between colons: "+1", "+1::skin-tone-3", "party_parrot". */ 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; /** The character for a standard code, '' for a custom or unknown one. */ function emojiChar(code) { const m = EMOJI_CODE_RE.exec(String(code || '')); if (!m) return ''; const base = EMOJI_NAMES[m[1].toLowerCase()]; if (!base) return ''; const tone = m[2] ? EMOJI_NAMES[m[2].toLowerCase()] : ''; return tone ? base.replace(/\uFE0F/g, '') + tone : base; } /* 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; const IMAGE_TTL_MS = 6 * 60 * 60 * 1000; const IMAGE_MAX_BYTES = 2 * 1024 * 1024; const imageCache = new Map(); // url → { buf, type, at } — module-wide, survives a re-init const imageInFlight = new Map(); function fetchImage(url) { const hit = imageCache.get(url); if (hit && Date.now() - hit.at < IMAGE_TTL_MS) return Promise.resolve(hit); if (imageInFlight.has(url)) return imageInFlight.get(url); const p = (async () => { const response = await fetch(url, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), redirect: 'follow' }); if (!response.ok) throw new Error(`HTTP ${response.status}`); const type = String(response.headers.get('content-type') || 'image/png').split(';')[0].trim(); if (!type.startsWith('image/')) throw new Error(`not an image (${type})`); const buf = Buffer.from(await response.arrayBuffer()); if (buf.length > IMAGE_MAX_BYTES) throw new Error('too large'); if (imageCache.size >= IMAGE_CACHE_MAX) imageCache.delete(imageCache.keys().next().value); const entry = { buf, type, at: Date.now() }; imageCache.set(url, entry); return entry; })().finally(() => imageInFlight.delete(url)); imageInFlight.set(url, p); return p; } /** Only https, or plain http on this machine (the mock) — never a URL a message could smuggle in. */ function imageUrlAllowed(url) { try { const u = new URL(String(url || '')); return u.protocol === 'https:' || (u.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(u.hostname)); } catch { return false; } } /** Answer with the image at `url`: 404 with none, 502 when the CDN fails — the then falls back. */ async function serveImage(res, url) { if (!imageUrlAllowed(url)) { res.writeHead(404, { 'Content-Type': 'text/plain', 'Cache-Control': 'no-store' }); return void res.end('No image'); } try { const img = await fetchImage(url); res.writeHead(200, { 'Content-Type': img.type, 'Content-Length': img.buf.length, 'Cache-Control': 'private, max-age=3600' }); res.end(img.buf); } catch { res.writeHead(502, { 'Content-Type': 'text/plain', 'Cache-Control': 'no-store' }); res.end('Image unavailable'); } } function unescapeSlack(s) { return s.replace(/</g, '<').replace(/>/g, '>').replace(/&/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; standard :codes: become their emoji, and a custom one * stays as :code: for the client to draw from the workspace's set. */ 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_+-]+(?:::skin-tone-[2-6])?):/gi, (m, code) => emojiChar(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(``); 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] : ''; } /** * Is the unlock token on this request good? Only the Send Message module * knows — it owns the PIN — so ask it over loopback. Answers { ok: true, * expiresAt } or { ok: false, status, error, code } ready to send back. */ async function verifyUnlock(req, shellPort) { const token = bearer(req); if (!token) return { ok: false, status: 401, error: 'Locked — enter the PIN', code: 'locked' }; if (!shellPort) return { ok: false, status: 503, error: 'This ProdDash cannot verify a PIN (needs 1.4.0 or newer)', code: 'no_shell_port' }; let res; try { res = await fetch(`http://127.0.0.1:${shellPort}/api/modules/${SENDER_ID}/verify`, { headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }, signal: AbortSignal.timeout(5000), }); } catch (err) { return { ok: false, status: 502, error: `Cannot reach the Send Message module (${err.cause?.code || err.message})`, code: 'sender_unreachable' }; } if (res.status === 404) return { ok: false, status: 503, error: 'Install the Send Message module to send 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([ '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, 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); const pollSeconds = Math.max(MIN_POLL_S, Number(config.pollSeconds) || 2); const pollMs = pollSeconds * 1000; const intervalMs = pollIntervalMs(pollSeconds, channelIds.length); 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))); /* ── 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 authPromise = null; // one auth.test in flight, shared by every poller let rateLimitedUntil = 0; // a 429 applies to the token, so every poller waits const pollers = new Map(); // channel id -> poller (configured order) 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 lastSig = ''; let lastInterest = Date.now(); const epoch = Date.now(); // the phase-locked slots count from here const streams = new Set(); /** * One channel's poller: its own window, thread cache, timer and error, * sharing the token, the name caches and the SSE fan-out with the rest. */ function makePoller(id, index) { const ch = { id, index, name: '', // from conversations.info (or the channel list) isMember: null, // null until conversations.info has answered isPrivate: false, checked: false, // conversations.info succeeded at least once error: '', // one sentence a tech can act on; '' while fine errorKind: '', // 'config' | 'membership' | 'poll' — decides the retry lastPollAt: 0, periodMs: intervalMs, // the cadence the last schedule used (for staleness) rawWindow: [], // conversations.history messages, as Slack sent them (newest first) threads: new Map(), // parent ts -> { latestReply, replyCount, replies: [raw…] } messagesCache: null, // normalised window, rebuilt when it or a name changed timer: null, polling: false, pollAgain: false, settleFirst: null, }; ch.firstRead = new Promise((resolve) => { ch.settleFirst = resolve; }); return ch; } channelIds.forEach((id, index) => pollers.set(id, makePoller(id, index))); /* ── 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) || pollers.get(id)?.name || ''; } /** A user's picture as Slack lists it (the 72 px one fits a transcript row). */ function profileImage(u) { const p = u?.profile || {}; return String(p.image_72 || p.image_48 || p.image_32 || p.image_original || ''); } /* Bot messages carry their own icon; remembered by bot so a later message from the same bot without icons (a plain bot_message) still has one. */ const botIcons = new Map(); // bot id / username → image url function botIconOf(m) { const icons = m.icons || m.bot_profile?.icons || {}; const url = String(icons.image_72 || icons.image_64 || icons.image_48 || icons.image_36 || ''); const key = String(m.bot_id || m.username || m.bot_profile?.id || 'bot'); if (url) botIcons.set(key, url); const emoji = icons.emoji ? emojiChar(String(icons.emoji).replace(/^:|:$/g, '')) : ''; return { key, url: url || botIcons.get(key) || '', emoji }; } /** [{ name, count, me }] for a message's reactions, or nothing. `me` = this account reacted. */ function reactionsOf(m) { if (!Array.isArray(m.reactions) || !m.reactions.length) return undefined; const meId = identity?.userId || ''; const out = []; for (const r of m.reactions) { const name = String(r?.name || ''); if (!EMOJI_CODE_RE.test(name)) continue; const users = Array.isArray(r.users) ? r.users : []; const count = Math.max(0, Math.trunc(Number(r.count)) || users.length); if (count > 0) out.push({ name, count, me: Boolean(meId && users.includes(meId)) }); } return out.length ? out : undefined; } /* ── the workspace's custom emoji (emoji.list, hourly) ── */ let customEmoji = new Map(); // name → { url } | { char } (an alias of a standard emoji) let customEmojiSig = ''; let emojiVersion = 0; // bumps when the set changes; tiles refetch /emoji then let emojiCheckedAt = 0; let emojiPromise = null; let emojiScopeMissing = false; function loadCustomEmoji() { if (emojiScopeMissing || Date.now() - emojiCheckedAt < EMOJI_LIST_TTL_MS) return Promise.resolve(); if (!emojiPromise) { emojiPromise = (async () => { emojiCheckedAt = Date.now(); const data = await slackCall(token, 'emoji.list', {}); const raw = data.emoji && typeof data.emoji === 'object' ? data.emoji : {}; const resolve = (name, depth) => { const v = raw[name]; if (typeof v !== 'string' || depth > 5) return null; if (v.startsWith('alias:')) { const to = v.slice('alias:'.length); if (to in raw) return resolve(to, depth + 1); const ch = emojiChar(to); return ch ? { char: ch } : null; } return imageUrlAllowed(v) ? { url: v } : null; }; const next = new Map(); for (const name of Object.keys(raw).sort()) { if (!EMOJI_CODE_RE.test(name) || name.includes('::')) continue; const e = resolve(name, 0); if (e) next.set(name, e); } const sig = JSON.stringify([...next]); if (sig !== customEmojiSig) { customEmoji = next; customEmojiSig = sig; emojiVersion += 1; invalidateAll(); log(`${next.size} custom emoji in the workspace`); } })().catch((err) => { if (err.code === 'missing_scope') { emojiScopeMissing = true; log('custom emoji stay as :codes: — the token lacks the emoji:read scope (add it under OAuth & Permissions, reinstall, paste the new token); standard emoji are unaffected'); } else { emojiCheckedAt = Date.now() - EMOJI_LIST_TTL_MS + RETRY_NETWORK_MS; // ask again soon if (err.code !== 'ratelimited') log(`emoji.list: ${describeError(err, { name: identity?.name })}`); } }).finally(() => { emojiPromise = null; }); } return emojiPromise; } function invalidateAll() { for (const ch of pollers.values()) ch.messagesCache = null; } 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, image: profileImage(u), 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() }); } })); if (changed) invalidateAll(); 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 }; // A poller that hasn't heard from conversations.info yet can take its name from here. for (const ch of pollers.values()) if (!ch.name && channelNames.get(ch.id)) ch.name = channelNames.get(ch.id); invalidateAll(); return all; })(); try { return await channelListPromise; } finally { channelListPromise = null; } } /* ── 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, ch, 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 || ''); const chanLabel = ch.name ? '#' + ch.name : 'the channel'; let text = textFor(m); if (m.subtype === 'channel_join' || m.subtype === 'group_join') text = `${name} joined ${chanLabel}`; else if (m.subtype === 'channel_leave' || m.subtype === 'group_leave') text = `${name} left ${chanLabel}`; const icon = m.user ? null : botIconOf(m); 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), // the picture in the row: a user's profile image or a bot's icon, by // key for GET /avatar/; a bot with only an :emoji: icon shows that avatar: m.user ? `u/${m.user}` : icon?.url ? `b/${icon.key}` : '', avatarEmoji: icon?.emoji || '', reactions: reactionsOf(m), }; if (parentTs) out.parent = parentTs; else { out.replyCount = Math.max(0, Math.trunc(Number(m.reply_count)) || 0); if (includeReplies && out.replyCount) { const t = ch.threads.get(out.ts); out.replies = t ? t.replies.map((r) => normalize(r, ch, out.ts)).filter(Boolean) : []; } } return out; } function messagesOf(ch) { if (!ch.messagesCache) { ch.messagesCache = ch.rawWindow .map((m) => normalize(m, ch)) .filter(Boolean) .sort((a, b) => Number(a.ts) - Number(b.ts)); } return ch.messagesCache; } 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 labelOf(ch) { return `#${ch.name || lookupChannel(ch.id) || ch.id}`; } function isStale(ch) { return ch.lastPollAt > 0 && Date.now() - ch.lastPollAt > 3 * ch.periodMs + 5000; } /** One channel's status as its transcript tile shows it (token trouble folded in). */ function channelStatus(ch) { if (!token) return { state: 'error', message: 'No Slack token — paste the location account’s User OAuth Token in Admin → Slack' }; if (authError) return { state: 'error', message: authError }; if (!identity) return { state: 'connecting', message: 'Checking the Slack token…' }; if (ch.error) return { state: 'error', message: ch.error }; if (!ch.lastPollAt) return { state: 'connecting', message: `${labelOf(ch)} · first read…` }; if (isStale(ch)) return { state: 'connecting', message: `${labelOf(ch)} · last read ${Math.round((Date.now() - ch.lastPollAt) / 1000)} s ago` }; return { state: 'ok', message: `${labelOf(ch)} · live` }; } /** 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 }; if (!identity) return { status: 'connecting', message: 'Checking the Slack token…' }; const who = identityLabel(); 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 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(' · ')}` }; if (list.some((ch) => !ch.lastPollAt)) return { status: 'connecting', message: `${who} · ${names} · first read…` }; const stale = list.filter(isStale); if (stale.length) { const ago = stale.map((ch) => `${labelOf(ch)} last read ${Math.round((Date.now() - ch.lastPollAt) / 1000)} s ago`).join(', '); return { status: 'connecting', message: `${who} · ${names} · ${ago}` }; } const cadence = intervalMs > pollMs ? ` · every ${intervalMs / 1000} s (rate budget)` : ''; return { status: 'ok', message: `${who} · ${names}${cadence}` }; } function channelView(ch) { return { id: ch.id, name: ch.name || lookupChannel(ch.id) || '', isMember: ch.isMember, isPrivate: ch.isPrivate, messages: messagesOf(ch), lastPollAt: ch.lastPollAt, pollAge: ch.lastPollAt ? Date.now() - ch.lastPollAt : null, error: ch.error, status: channelStatus(ch), }; } function snapshot() { const h = health(); const channels = {}; let lastPollAt = 0; for (const ch of pollers.values()) { channels[ch.id] = channelView(ch); lastPollAt = Math.max(lastPollAt, ch.lastPollAt); } return { identity: identity ? { kind: identity.kind, name: identity.name, team: identity.team, sendAs: identity.kind === 'bot' ? sendAs : '' } : null, sendingAs: sendingAs(), channelOrder: [...pollers.keys()], channels, includeReplies, historyCount, pollSeconds: intervalMs / 1000, emojiVersion, status: { state: h.status, message: h.message, lastPollAt }, apiBase: API_BASE, }; } /** Broadcast the snapshot when anything a tile shows has changed (ages don't count). */ function broadcastIfChanged() { const snap = snapshot(); const sig = JSON.stringify(snap, (key, value) => (key === 'pollAge' || key === 'lastPollAt' ? undefined : value)); 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 loops: token → channel → history (+ threads, names) ── */ function watched() { return streams.size > 0 || Date.now() - lastInterest < IDLE_AFTER_MS; } /** * Delay to poller `index`'s next slot at cadence `periodMs`: slots are * spread evenly across the period from `epoch`, so channels never read * in a burst, and a slot that has just passed is skipped, not fired late. */ function slotDelay(index, periodMs) { const phase = Math.round((index * periodMs) / Math.max(1, pollers.size)); const elapsed = Date.now() - epoch; const k = Math.floor((elapsed - phase) / periodMs) + 1; let delay = phase + k * periodMs - elapsed; if (delay < MIN_SLOT_GAP_MS) delay += periodMs; return delay; } /** `plainMs` for an error retry; without it, the regular phase-locked cadence. */ function scheduleNext(ch, plainMs = null) { if (stopped) return; clearTimeout(ch.timer); let delay = plainMs; if (delay === null) { ch.periodMs = watched() ? intervalMs : Math.max(intervalMs, IDLE_POLL_S * 1000); delay = slotDelay(ch.index, ch.periodMs); } ch.timer = setTimeout(() => tick(ch), delay); ch.timer.unref?.(); } function pollSoon(ch, delay = 250) { if (stopped) return; if (ch.polling) { ch.pollAgain = true; return; } clearTimeout(ch.timer); ch.timer = setTimeout(() => tick(ch), delay); ch.timer.unref?.(); } 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, image: profileImage(u.user), at: Date.now() }); } catch { /* the handle will do */ } } identity = id; authError = ''; invalidateAll(); 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' : ''}`); if (tokenScopes) { const have = new Set(tokenScopes.split(',').map((x) => x.trim())); const missing = OPTIONAL_SCOPES.filter(([scope]) => !have.has(scope)); log(`token scopes: ${tokenScopes}`); if (missing.length) { log(`without ${missing.map(([scope, what]) => `${scope} (${what})`).join(' and ')} — add under OAuth & Permissions → User Token Scopes, Reinstall to Workspace as this account, paste the token it shows`); } } } /** Every poller wants the identity; the first to ask makes the one call. */ function ensureAuth() { if (identity) return Promise.resolve(); if (!authPromise) { authPromise = checkAuth().finally(() => { authPromise = null; }); } return authPromise; } async function checkChannel(ch) { const data = await slackCall(token, 'conversations.info', { channel: ch.id }); const c = data.channel || {}; ch.name = String(c.name || c.name_normalized || ch.id); ch.isPrivate = Boolean(c.is_private); ch.isMember = c.is_member !== false; ch.checked = true; channelNames.set(ch.id, ch.name); invalidateAll(); if (!ch.isMember) throw new SlackError('not_in_channel'); } async function readHistory(ch) { const data = await slackCall(token, 'conversations.history', { channel: ch.id, limit: historyCount }); const messages = Array.isArray(data.messages) ? data.messages.filter((m) => m && m.ts) : []; ch.rawWindow = messages; ch.messagesCache = null; ch.lastPollAt = Date.now(); ch.isMember = true; // 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 [...ch.threads.keys()]) if (!inWindow.has(ts)) ch.threads.delete(ts); if (includeReplies) { const stale = messages .filter((m) => Number(m.reply_count) > 0) .filter((m) => { const t = ch.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, threadFetchesPerPoll); for (const parent of stale) { try { const rep = await slackCall(token, 'conversations.replies', { channel: ch.id, ts: parent.ts, limit: THREAD_REPLIES_LIMIT }); const replies = (rep.messages || []).filter((r) => r && r.ts && String(r.ts) !== String(parent.ts)); ch.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(`${labelOf(ch)} thread ${parent.ts}: ${describeError(err, { name: identity?.name, channel: ch.name })}`); } } } } /** Record a failed poll on its channel (or the token); returns the retry delay. */ function failed(ch, err) { const text = describeError(err, { name: identity?.name, channel: ch.name || ch.id }); if (!identity) { if (authError !== text) log(text); authError = text; return isConfigError(err) ? RETRY_CONFIG_MS : RETRY_NETWORK_MS; } let retry; if (err.code === 'ratelimited') { rateLimitedUntil = Date.now() + err.retryAfter * 1000; ch.errorKind = 'poll'; retry = err.retryAfter * 1000 + 100 + ch.index * 100; } else if (err.code === 'not_in_channel') { ch.isMember = false; ch.errorKind = 'membership'; retry = RETRY_MEMBERSHIP_MS; } else if (isConfigError(err)) { ch.errorKind = 'config'; retry = RETRY_CONFIG_MS; } else { ch.errorKind = 'poll'; retry = Math.max(intervalMs, RETRY_NETWORK_MS); } if (ch.error !== text) log(`${labelOf(ch)}: ${text}`); ch.error = text; return retry; } async function tick(ch) { if (stopped || ch.polling) return; ch.polling = true; ch.pollAgain = false; let retry = null; // a plain delay for error retries; null → the regular cadence try { const wait = rateLimitedUntil - Date.now(); if (wait > 0) { retry = wait + 100 + ch.index * 100; } else { await ensureAuth(); await loadCustomEmoji(); if (!ch.checked || ch.errorKind === 'membership') await checkChannel(ch); await readHistory(ch); if (ch.error) log(`${labelOf(ch)}: reading again`); ch.error = ''; ch.errorKind = ''; await resolveUsers(); } } catch (err) { retry = failed(ch, err); } finally { ch.polling = false; if (ch.settleFirst) { ch.settleFirst(); ch.settleFirst = null; } broadcastIfChanged(); if (ch.pollAgain) pollSoon(ch); else scheduleNext(ch, retry); } } /** * tiles() names each transcript after its channel; right after boot the * names are still on their way, so it waits (briefly) for the first * conversations.info answers rather than baking "#C0123" into tile titles. */ function namesReady() { if (!token || authError || stopped) return Promise.resolve(); const pending = [...pollers.values()].filter((ch) => !ch.checked && !ch.error).map((ch) => ch.firstRead); if (!pending.length) return Promise.resolve(); return Promise.race([ Promise.all(pending), new Promise((resolve) => { setTimeout(resolve, NAMES_WAIT_MS).unref?.(); }), ]); } /** * 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) 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) { 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 }); } /** 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 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, 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 = 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?' }); const code = String(name ?? '').trim().replace(/^:|:$/g, '').toLowerCase(); if (!EMOJI_CODE_RE.test(code)) return sendJson(res, 400, { error: 'Not an emoji name' }); const targetName = lookupChannel(target.id) || target.id; try { await slackCall(token, remove ? 'reactions.remove' : 'reactions.add', { channel: target.id, timestamp: stamp, name: code }); } catch (err) { if (err.code !== 'already_reacted' && err.code !== 'no_reaction') { const message = describeError(err, { name: identity.name, channel: targetName }); log(`reaction :${code}: in #${targetName} failed: ${message}`); const status = ['invalid_name', 'too_many_reactions', 'too_many_emoji'].includes(err.code) ? 400 : err.code === 'message_not_found' ? 404 : 502; return sendJson(res, status, { error: message, code: err.code }); } } 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: verified.expiresAt, channel: `#${targetName}` }); } /** 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' }); } const targetName = lookupChannel(targetId) || targetId; try { 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(targetId)) pollSoon(pollers.get(targetId)); return sendJson(res, 200, { ok: true, ts: String(data.ts || ''), label: `#${targetName}`, channel: `#${targetName}`, channelId: targetId, sendingAs: sendingAs(), expiresAt: verified.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?.(); if (!token) log('no Slack token configured — paste one in /admin'); else { if (!channelIds.length) log('no channels configured — pick some in /admin → Slack → Channels'); else { if (!Array.isArray(config.channels) || !config.channels.length) { log(`using the single channel saved by an earlier version (${channelIds[0]}) — pick it under Channels in /admin to keep it`); } const budgeted = Math.ceil((channelIds.length * 60) / HISTORY_BUDGET_PER_MIN); log(`polling ${channelIds.length} channel${channelIds.length === 1 ? '' : 's'} every ${intervalMs / 1000} s each — max(${pollSeconds} s, ceil(${channelIds.length}×60/${HISTORY_BUDGET_PER_MIN}) = ${budgeted} s) keeps history reads at ≤${HISTORY_BUDGET_PER_MIN}/min`); for (const ch of pollers.values()) scheduleNext(ch, FIRST_READ_MS + ch.index * FIRST_READ_STAGGER_MS); } } current = { streams, snapshot, health, shellPort, messagingDescriptor, deliver, react, /** Everything a tile needs to draw emoji: the shipped table plus the workspace's own. */ emojiTable() { const custom = {}; for (const [name, e] of customEmoji) { custom[name] = e.url ? { url: `/api/modules/slack/emoji-image/${encodeURIComponent(name)}` } : { char: e.char }; } return { standard: EMOJI_NAMES, custom, version: emojiVersion }; }, customEmojiUrl: (name) => customEmoji.get(String(name || ''))?.url || '', /** "u/U123" → that user's profile image; "b/" → that bot's icon. */ avatarUrl(key) { const [kind, id] = String(key || '').split('/'); if (kind === 'u') return users.get(id)?.image || ''; if (kind === 'b') return botIcons.get(id) || ''; return ''; }, chooseTarget, namesReady, touch() { lastInterest = Date.now(); }, wake() { lastInterest = Date.now(); if (!identity) return; // Somebody is looking again: catch every channel up, one after another. let n = 0; for (const ch of pollers.values()) { if (ch.checked && !ch.error && Date.now() - ch.lastPollAt > intervalMs) pollSoon(ch, 250 + 150 * n++); } }, loadChannelList, channelIds, channelName: (id) => lookupChannel(id), identityName: () => identity?.name || '', sendingAs, }; return { stop() { stopped = true; for (const ch of pollers.values()) { clearTimeout(ch.timer); if (ch.settleFirst) { ch.settleFirst(); ch.settleFirst = null; } } clearInterval(heartbeat); for (const res of streams) { try { res.end(); } catch { /* already gone */ } } streams.clear(); if (current && current.streams === streams) current = null; }, health, }; }, /** * The picker: one transcript per configured channel, titled with the * 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 * — "#" 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(); return ids.map((id) => ({ id: `transcript:${id}`, name: `#${(c && c.channelName(id)) || id}`, description: 'Live transcript', defaultSize: { w: 4, h: 4 }, minSize: { w: 2, h: 2 }, settings: { view: 'transcript', channel: id }, })); }, 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({ channelOrder: [], channels: {}, 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 "Channels" multiselect: 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 values checked. sendJson(res, 200, { options: [], error: describeError(err, { name: current.identityName() }) }); } }), // 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); sendJson(res, 200, current.emojiTable()); }, // Pictures proxied from Slack's CDN: a custom emoji by name, a profile image or bot icon by key. 'GET /emoji-image/*': guard(async (req, res) => { if (!current) return unmounted(res); await serveImage(res, current.customEmojiUrl(decodeURIComponent(String(req.wildcard || '').replace(/^\/+/, '')))); }), 'GET /avatar/*': guard(async (req, res) => { if (!current) return unmounted(res); await serveImage(res, current.avatarUrl(decodeURIComponent(String(req.wildcard || '').replace(/^\/+/, '')))); }), // The Send Message module reads this over loopback: where it may send. 'GET /messaging': (req, res) => { if (!current) return unmounted(res); sendJson(res, 200, current.messagingDescriptor()); }, // …and hands a message here, with the browser's unlock token. The token // is checked with the Send Message module first: no PIN entered there, // nothing is posted. 'POST /messaging/send': guard(async (req, res) => { if (!current) return unmounted(res); const v = await verifyUnlock(req, current.shellPort); if (!v.ok) return sendJson(res, v.status, { error: v.error, code: v.code }); let body; try { body = await readJson(req); } catch (err) { return sendJson(res, 400, { error: err.message }); } const text = String(body.text ?? '').replace(/\r\n/g, '\n').trim(); if (!text) return sendJson(res, 400, { error: 'Nothing to send' }); if (text.length > MAX_TEXT_CHARS) return sendJson(res, 400, { error: `Too long — ${MAX_TEXT_CHARS} characters at most` }); const 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' }); }), // A reaction as the location — the same unlock as a send. 'POST /react': guard(async (req, res) => { if (!current) return unmounted(res); const v = await verifyUnlock(req, current.shellPort); if (!v.ok) return sendJson(res, v.status, { error: v.error, code: v.code }); let body; try { body = await readJson(req); } catch (err) { return sendJson(res, 400, { error: err.message }); } await current.react(req, res, v, { channel: body.channel, ts: body.ts, name: body.name, remove: Boolean(body.remove) }); }), }; }, };