diff --git a/modules/slack/client.js b/modules/slack/client.js index 761e243..9c6e60e 100644 --- a/modules/slack/client.js +++ b/modules/slack/client.js @@ -1,9 +1,11 @@ /* Slack — client part. - Three tiles from one module (moduleApi.variant picks): the channel - transcript, a PIN-gated free-text sender and a PIN-gated grid of preset - replies. Everything lives inside the tile's root; all traffic goes through - the module's server routes (the token and the PIN never reach a browser). + Tiles from one module (moduleApi.variant / the tile's `view` setting pick): + one channel transcript per configured channel, a PIN-gated free-text sender + and a PIN-gated grid of preset replies. The two senders post to the channel + of whichever transcript was clicked last (see the send target below). + Everything lives inside the tile's root; all traffic goes through the + module's server routes (the token and the PIN never reach a browser). Nothing here uses innerHTML with data — every string from Slack lands in a text node. */ @@ -63,15 +65,88 @@ async function readJson(res) { } } +const NO_CHANNEL = 'No channel configured — pick channels in Admin → Slack → Channels'; + const LOCK_SVG = ''; const JUMP_SVG = ''; const BACK_SVG = ''; const CHECK_SVG = ''; +/* ── the send target ────────────────────────────────────────────────── */ + +/* Deliberately module-level. The guide forbids module-level *per-instance* + state — two tiles' closures must never leak into each other — and every + piece of per-tile state in this file still lives inside its create(). This + is a different kind of thing: one fact about the whole page, "which + transcript did the operator click last", that Send message, Quick replies + and every transcript tile on this dashboard have to agree on. It is UI + state of this browser, not of the machine (another screen in the booth may + be pointed at another channel), so it stays out of the server and lives + here: an EventTarget plus the chosen channel id, mirrored in localStorage + under proddash:slack:sendTarget so a reload keeps it. */ +const SEND_TARGET_KEY = 'proddash:slack:sendTarget'; + +const sendTarget = (() => { + const bus = new EventTarget(); + const transcripts = new Map(); // instanceId -> channel id, for every transcript tile on the page + let id = ''; + try { + id = String(localStorage.getItem(SEND_TARGET_KEY) || ''); + } catch { /* private mode or blocked storage — the choice lasts until reload */ } + const emit = () => bus.dispatchEvent(new Event('change')); + return { + /** The channel the operator clicked last ('' if never). */ + get() { + return id; + }, + /** A transcript was clicked: remember it and tell every Slack tile. */ + select(channelId) { + const next = String(channelId || ''); + if (next === id) return; + id = next; + try { + localStorage.setItem(SEND_TARGET_KEY, id); + } catch { /* fine — see above */ } + emit(); + }, + /** Transcript tiles register, so the fallback rule can tell a selected channel whose tile is gone. */ + attach(instanceId, channelId) { + if (transcripts.get(instanceId) === channelId) return; + transcripts.set(instanceId, channelId); + emit(); + }, + detach(instanceId) { + if (transcripts.delete(instanceId)) emit(); + }, + hasTranscript(channelId) { + for (const v of transcripts.values()) if (v === channelId) return true; + return false; + }, + /** + * Where a send goes, given the configured channels in admin order: the + * selection while it is still configured and its transcript is on the + * page; otherwise the first configured channel. + */ + resolve(configured) { + const list = Array.isArray(configured) ? configured : []; + if (!list.length) return ''; + if (id && list.includes(id) && this.hasTranscript(id)) return id; + return list[0]; + }, + on(fn) { + bus.addEventListener('change', fn); + return () => bus.removeEventListener('change', fn); + }, + }; +})(); + /* ── the factory ────────────────────────────────────────────────────── */ export default function create({ root, moduleApi }) { - const view = String(moduleApi.variant || moduleApi.instanceSettings.view || 'transcript'); + const variant = String(moduleApi.variant || ''); + // The entry's preset `view` is authoritative; a tile added under 1.0.0 has + // the plain variant "transcript", a new one "transcript:". + const view = String(moduleApi.instanceSettings.view || (variant.startsWith('transcript') ? 'transcript' : variant) || 'transcript'); if (view === 'send' || view === 'quick') return createSendTile({ root, moduleApi, mode: view }); return createTranscriptTile({ root, moduleApi }); } @@ -80,10 +155,16 @@ export default function create({ root, moduleApi }) { function createTranscriptTile({ root, moduleApi }) { const entries = new Map(); // ts -> { el, sig, ... } + // The channel this tile shows: preset by the picker entry; a tile from + // 1.0.0 has none and follows the first configured channel. + const pinnedChannel = String(moduleApi.instanceSettings.channel || ''); + let channelId = pinnedChannel; + let lastState = null; let pinnedToLatest = true; let painted = false; let stream = null; let stopped = false; + let targetTimer = null; root.innerHTML = `
@@ -143,6 +224,29 @@ function createTranscriptTile({ root, moduleApi }) { streamEl.scrollTop = streamEl.scrollHeight; } + /* the send target: a click anywhere in the body points the senders here */ + wrap.addEventListener('click', () => { + if (!channelId || !lastState?.channels?.[channelId]) return; // not configured — can't be a target + sendTarget.select(channelId); + }); + const offTarget = sendTarget.on(() => { + // Coalesce: a remount detaches and re-attaches within a few ms. + clearTimeout(targetTimer); + targetTimer = setTimeout(paintTarget, 50); + }); + + function isTarget() { + return Boolean(channelId) && sendTarget.resolve(lastState?.channelOrder) === channelId; + } + + function paintTarget() { + if (stopped) return; + const on = isTarget(); + if (on === wrap.classList.contains('is-target')) return; + wrap.classList.toggle('is-target', on); + if (lastState) reportStatus(lastState); + } + /* entries */ function buildMeta(m) { const meta = el('div', 'sl-meta'); @@ -228,15 +332,26 @@ function createTranscriptTile({ root, moduleApi }) { else if (added) jumpBtn.hidden = false; } - function applyState(s) { - if (stopped || !s || typeof s !== 'object') return; - const st = s.status || {}; - const chan = s.channel?.name ? '#' + s.channel.name : ''; - const who = s.identity?.name ? ` as ${s.identity.name}` : ''; - renderMessages(Array.isArray(s.messages) ? s.messages : []); + /** The status dot, banner and empty-state text for the current state. */ + function reportStatus(s) { + const ch = channelId && s.channels && typeof s.channels === 'object' ? s.channels[channelId] : null; const hasEntries = entries.size > 0; + const who = s.identity?.name ? ` as ${s.identity.name}` : ''; + let problem = ''; + if (!channelId) problem = NO_CHANNEL; + else if (!ch) problem = `#${channelId} is no longer one of this module's channels — pick it again in Admin → Slack → Channels, or remove this tile`; + if (problem) { + moduleApi.setStatus('error', problem); + banner.hidden = true; + emptyEl.hidden = false; + emptyEl.textContent = problem; + emptyEl.classList.add('is-error'); + return; + } + const chan = `#${ch.name || channelId}`; + const st = ch.status || {}; if (st.state === 'ok') { - moduleApi.setStatus('ok', `Live — ${chan}${who}`); + moduleApi.setStatus('ok', `Live — ${chan}${who}${isTarget() ? ' · sends go here' : ''}`); banner.hidden = true; } else { moduleApi.setStatus(st.state === 'error' ? 'error' : 'connecting', st.message || 'Connecting to Slack…'); @@ -248,11 +363,24 @@ function createTranscriptTile({ root, moduleApi }) { } emptyEl.hidden = hasEntries; if (!hasEntries) { - emptyEl.textContent = st.state !== 'ok' - ? (st.message || 'Connecting to Slack…') - : chan ? `No messages yet in ${chan}` : 'No channel chosen — pick one in Admin → Slack'; + emptyEl.textContent = st.state !== 'ok' ? (st.message || 'Connecting to Slack…') : `No messages yet in ${chan}`; emptyEl.classList.toggle('is-error', st.state === 'error'); } + } + + function applyState(s) { + if (stopped || !s || typeof s !== 'object') return; + lastState = s; + const order = Array.isArray(s.channelOrder) ? s.channelOrder.map(String) : []; + channelId = pinnedChannel || order[0] || ''; + const ch = channelId && s.channels && typeof s.channels === 'object' ? s.channels[channelId] : null; + // Registered while its channel is configured: that is what makes it a + // possible send target (see sendTarget.resolve). + if (ch) sendTarget.attach(moduleApi.instanceId, channelId); + else sendTarget.detach(moduleApi.instanceId); + renderMessages(ch && Array.isArray(ch.messages) ? ch.messages : []); + wrap.classList.toggle('is-target', isTarget()); + reportStatus(s); painted = true; } @@ -300,6 +428,9 @@ function createTranscriptTile({ root, moduleApi }) { }, stop() { stopped = true; + clearTimeout(targetTimer); + offTarget(); + sendTarget.detach(moduleApi.instanceId); stream?.close(); root.innerHTML = ''; // header controls are removed by the shell }, @@ -314,7 +445,7 @@ function createTranscriptTile({ root, moduleApi }) { function createSendTile({ root, moduleApi, mode }) { let token = ''; // the unlock token — memory only; a remount shows the pad again let expiresAt = 0; - let state = null; // latest snapshot: sending flags, identity, channel, status + let state = null; // latest snapshot: sending flags, identity, channels, status let stream = null; let ticker = null; let stopped = false; @@ -324,6 +455,7 @@ function createSendTile({ root, moduleApi, mode }) { let gateError = ''; // the server's refusal ("Wrong PIN", "locked for 30 s") let gateKind = ''; // what the gate currently shows, to avoid needless rebuilds let presets = null; + let targetTimer = null; root.innerHTML = `
@@ -342,12 +474,49 @@ function createSendTile({ root, moduleApi, mode }) { } applyInstanceSettings(); - function sendingLine() { - const who = state?.sendingAs || '…'; - const chan = state?.channel?.name ? '#' + state.channel.name : 'no channel'; - return `Sending as ${who} → ${chan}`; + /* ── where a send goes ── */ + function configured() { + return Array.isArray(state?.channelOrder) ? state.channelOrder.map(String) : []; } + function targetId() { + return sendTarget.resolve(configured()); + } + + function targetName() { + const id = targetId(); + if (!id) return ''; + const ch = state?.channels?.[id]; + return `#${ch?.name || id}`; + } + + function sendingLine() { + if (state && !configured().length) return NO_CHANNEL; + const who = state?.sendingAs || '…'; + return `Sending as ${who} → ${targetName() || '…'}`; + } + + function placeholder() { + const name = targetName(); + return name ? `Message ${name}…` : 'Message…'; + } + + /** The selection changed somewhere on the page: every line that names the target follows. */ + function repaintTarget() { + if (stopped || !state) return; + if (token) { + if (composeEls) { + composeEls.line.textContent = sendingLine(); + if (composeEls.input) composeEls.input.placeholder = placeholder(); + } + } else renderGate(); + moduleApi.setStatus(statusState(), `${statusMessage()} · ${token ? 'unlocked' : 'locked'}`); + } + const offTarget = sendTarget.on(() => { + clearTimeout(targetTimer); + targetTimer = setTimeout(repaintTarget, 50); + }); + function authHeaders(extra = {}) { return { Authorization: `Bearer ${token}`, ...extra }; } @@ -361,6 +530,7 @@ function createSendTile({ root, moduleApi, mode }) { if (!state) kind = 'connecting'; else if (!state.sending?.enabled) kind = 'off'; else if (!state.sending?.pinSet) kind = 'nopin'; + else if (!configured().length) kind = 'nochannel'; else kind = 'pad'; if (kind !== gateKind) { gateKind = kind; @@ -370,7 +540,8 @@ function createSendTile({ root, moduleApi, mode }) { const note = el('div', 'sl-note'); note.textContent = kind === 'connecting' ? 'Connecting to ProdDash…' : kind === 'off' ? 'Sending is off — turn it on in Admin → Slack → Sending' - : 'Set a PIN in Admin → Slack → Sending'; + : kind === 'nopin' ? 'Set a PIN in Admin → Slack → Sending' + : NO_CHANNEL; gate.appendChild(note); } } @@ -507,7 +678,7 @@ function createSendTile({ root, moduleApi, mode }) { if (mode === 'send') { input = el('textarea', 'sl-input'); input.rows = 3; - input.placeholder = state?.channel?.name ? `Message #${state.channel.name}…` : 'Message…'; + input.placeholder = placeholder(); input.spellcheck = true; input.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { @@ -564,20 +735,22 @@ function createSendTile({ root, moduleApi, mode }) { if (busy || !composeEls?.input) return; const text = composeEls.input.value.trim(); if (!text) return; + const channel = targetId(); + if (!channel) return void showError(NO_CHANNEL); busy = true; composeEls.sendBtn.disabled = true; try { const res = await moduleApi.fetch('/send', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), - body: JSON.stringify({ text }), + body: JSON.stringify({ text, channel }), }); const body = await readJson(res); if (res.status === 401) return void lock(body.error || 'Locked — enter the PIN', true); if (!res.ok) return void showError(body.error || `Send failed (HTTP ${res.status})`); composeEls.input.value = ''; if (body.expiresAt) expiresAt = Number(body.expiresAt); - flash('Sent ✓'); + flash(`Sent to ${body.channel || targetName()} ✓`); } catch { showError('ProdDash unreachable — not sent'); } finally { @@ -620,6 +793,7 @@ function createSendTile({ root, moduleApi, mode }) { const b = el('button', 'sl-quick'); b.type = 'button'; b.appendChild(el('span', 'sl-quick-label', p.label)); + // A preset bound to its own channel says so — it ignores the selection. if (p.channel) b.appendChild(el('span', 'sl-quick-chan', `→ ${p.channel}`)); b.addEventListener('click', () => sendQuick(p.index, b)); grid.appendChild(b); @@ -632,10 +806,11 @@ function createSendTile({ root, moduleApi, mode }) { btn.disabled = true; btn.classList.add('is-busy'); try { + const channel = targetId(); const res = await moduleApi.fetch('/quick', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), - body: JSON.stringify({ index }), + body: JSON.stringify(channel ? { index, channel } : { index }), }); const body = await readJson(res); if (res.status === 401) return void lock(body.error || 'Locked — enter the PIN', true); @@ -650,6 +825,7 @@ function createSendTile({ root, moduleApi, mode }) { const label = btn.querySelector('.sl-quick-label'); const original = label.textContent; label.textContent = 'Sent ✓'; + flash(`Sent to ${body.channel || targetName()} ✓`); setTimeout(() => { if (!btn.isConnected) return; label.textContent = original; @@ -704,6 +880,7 @@ function createSendTile({ root, moduleApi, mode }) { return st === 'ok' || st === 'connecting' || st === 'error' ? st : 'connecting'; } function statusMessage() { + if (state?.status?.state === 'ok') return sendingLine(); return state?.status?.message || 'Connecting to Slack…'; } @@ -717,7 +894,7 @@ function createSendTile({ root, moduleApi, mode }) { if (!s.sending?.enabled || !s.sending?.pinSet) return void lock(''); if (composeEls) { composeEls.line.textContent = sendingLine(); - if (composeEls.input && s.channel?.name) composeEls.input.placeholder = `Message #${s.channel.name}…`; + if (composeEls.input) composeEls.input.placeholder = placeholder(); } } else renderGate(); } @@ -757,6 +934,8 @@ function createSendTile({ root, moduleApi, mode }) { stopped = true; clearInterval(ticker); clearTimeout(flash.timer); + clearTimeout(targetTimer); + offTarget(); stream?.close(); token = ''; // the unlock dies with the tile root.innerHTML = ''; diff --git a/modules/slack/module.json b/modules/slack/module.json index 3b82a58..7f8da9d 100644 --- a/modules/slack/module.json +++ b/modules/slack/module.json @@ -1,9 +1,9 @@ { "id": "slack", "name": "Slack", - "version": "1.0.0", + "version": "1.1.0", "proddash": ">=1.4.0", - "description": "A Slack channel's live transcript, plus PIN-protected sending as this location", + "description": "Live transcripts of this location's Slack channels, plus PIN-protected sending as the location", "client": "client.js", "server": "server.js", "style": "style.css", @@ -14,9 +14,12 @@ "collapsed": "whenSet", "help": "Create a Slack app in the workspace (api.slack.com/apps → Create New App → From scratch) and, under OAuth & Permissions → User Token Scopes, add channels:history, channels:read, groups:history, groups:read, chat:write and users:read (users:read.email is not needed). Sign in to Slack as this location's account — the one named for the campus — open the app's OAuth & Permissions page and press Install to Workspace (or Reinstall to Workspace), then copy the User OAuth Token (xoxp-…) it shows and paste it here: messages from this machine are then literally from the location. A bot token (xoxb-…) works too; it posts as the app, with \"Send as\" naming the location." }, + "Channels": { + "help": "Every channel picked here becomes its own transcript tile in the + picker, titled with the channel's name. The Send message and Quick replies tiles post to whichever transcript was clicked last — its bottom edge lights up in the accent colour — and to the first channel here until one is clicked. Only channels the account has joined are listed; join one in Slack first. Each channel is read on its own schedule, all of them together kept inside Slack's rate limit." + }, "Sending": { "toggle": "sendEnabled", - "help": "Messages leave this machine only from a Send or Quick replies tile, and only after the PIN is entered on that tile — every tile asks again after Auto-lock minutes without a send. Sending stays refused until a 4–8 digit PIN is set here. Quick replies are one per line, Label | Message, with an optional | #channel-name to send that one somewhere other than the transcript channel." + "help": "Messages leave this machine only from a Send or Quick replies tile, and only after the PIN is entered on that tile — every tile asks again after Auto-lock minutes without a send. Sending stays refused until a 4–8 digit PIN is set here. A message goes to the channel of the transcript clicked last. Quick replies are one per line, Label | Message, with an optional | #channel-name to bind that one to a channel: a bound reply always goes there, whichever transcript is selected." } }, "configSchema": { @@ -34,20 +37,20 @@ "group": "Slack account", "help": "Only used with a bot token — a user token always sends as its own account; needs the chat:write.customize scope." }, - "channel": { - "type": "select", - "label": "Channel", - "default": "", - "group": "Channel", + "channels": { + "type": "multiselect", + "label": "Channels", + "default": [], + "group": "Channels", "optionsRoute": "/channels", - "help": "The channel the transcript shows and messages go to by default. Only channels the account has joined are listed — join one in Slack first." + "help": "One transcript tile per channel; the first one is where sends go until a transcript is clicked. Only channels the account has joined are listed — join one in Slack first." }, "pollSeconds": { "type": "number", "label": "Refresh interval (s)", "default": 2, "group": "Transcript", - "help": "How often the channel is re-read (1 s at the fastest)." + "help": "How often each channel is re-read (1 s at the fastest). With several channels the interval stretches so the module stays inside Slack's rate limit — at least 1.5 s × the number of channels; the health line shows the interval in use." }, "historyCount": { "type": "number", @@ -85,7 +88,7 @@ "label": "Quick replies", "default": "", "group": "Sending", - "help": "One per line: Label | Message. Add | #channel-name to send one somewhere else." + "help": "One per line: Label | Message. Add | #channel-name to bind one to a channel — it then ignores which transcript is selected." } }, "instanceGroups": { diff --git a/modules/slack/server.js b/modules/slack/server.js index 854c3b0..c8e064a 100644 --- a/modules/slack/server.js +++ b/modules/slack/server.js @@ -4,19 +4,40 @@ * 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. + * location account (a user token), or a bot — polling the configured + * channels' recent history (one poller per channel, all inside one rate + * budget) and fanning the lot out to every tile over SSE, plus PIN-gated + * sending from this machine so a message reads as coming from the location. + * Browsers never talk to Slack; the token and the PIN stay here. * - * GET /state snapshot: identity, channel, messages, sending state + * GET /state snapshot: identity, channels (keyed by id), sending state * GET /stream SSE `state` events (change-detected, heartbeated) - * GET /channels admin select options — the channels the account is in + * GET /channels admin multiselect options — the channels the account is in * POST /unlock { pin } → { token, expiresAt } (constant-time, lockout) * POST /lock Bearer → forget that unlock token * GET /quick Bearer → preset labels + indexes (never the message text) - * POST /send Bearer, { text, channel? } → chat.postMessage - * POST /quick Bearer, { index } → post that preset + * POST /send Bearer, { text, channel } → chat.postMessage + * POST /quick Bearer, { index, channel } → post that preset + * + * `channel` on /send and /quick must be one of the configured channel ids + * (400 otherwise): a PIN-unlocked browser chooses among the admin's channels, + * never an arbitrary one. A quick reply with its own "| #channel" ignores the + * request's channel — an explicit binding wins over the selection. + * + * 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. @@ -33,13 +54,20 @@ const REQUEST_TIMEOUT_MS = 10000; 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 (oldest parent first) — keeps a busy channel inside Tier 3. */ +/** 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; @@ -50,6 +78,13 @@ const RETRY_NETWORK_MS = 10000; 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 PIN_RE = /^\d{4,8}$/; const PIN_MAX_WRONG = 5; const PIN_LOCKOUT_MS = 30000; @@ -62,6 +97,38 @@ const UNLOCK_SWEEP_MS = 30000; /** 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 { @@ -133,7 +200,7 @@ function describeError(err, ctx = {}) { 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 '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)'; @@ -284,7 +351,8 @@ function pinMatches(given, expected) { /** * "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. + * and its message; the channel (optional) is a name the account can see — + * a reply that carries one always goes there, whatever transcript is selected. */ function parseQuickReplies(text) { const out = []; @@ -315,10 +383,13 @@ module.exports = { /* ── 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 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))); const sendEnabled = Boolean(config.sendEnabled); const pin = String(config.pin || '').trim(); const pinOk = PIN_RE.test(pin); @@ -329,28 +400,50 @@ module.exports = { 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…] } + 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 snapshotCache = null; let lastSig = ''; let lastInterest = Date.now(); - let pollTimer = null; - let polling = false; - let pollAgain = false; - let currentIntervalMs = pollMs; + const epoch = Date.now(); // the phase-locked slots count from here const streams = new Set(); const unlocks = new Map(); // token -> { expiresAt, lastSendAt, ip } const pinAttempts = new Map(); // ip -> { count, lockedUntil } + /** + * One channel's poller: its own window, thread cache, timer and error, + * 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 ''; @@ -361,7 +454,11 @@ module.exports = { } function lookupChannel(id) { - return channelNames.get(id) || ''; + return channelNames.get(id) || pollers.get(id)?.name || ''; + } + + function invalidateAll() { + for (const ch of pollers.values()) ch.messagesCache = null; } async function resolveUsers() { @@ -382,6 +479,7 @@ module.exports = { if (err.code !== 'network' && err.code !== 'ratelimited') users.set(id, { name: id, at: Date.now() }); } })); + if (changed) invalidateAll(); return changed; } @@ -409,6 +507,9 @@ module.exports = { } 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 { @@ -419,9 +520,9 @@ module.exports = { } /** "#name" or a channel id → the channel id, or '' when the account can't see it. */ - async function resolveChannel(ref) { + async function resolveChannelRef(ref) { const s = String(ref || '').trim(); - if (!s) return channelId; + if (!s) return ''; if (/^[CG][A-Z0-9]{6,}$/.test(s)) return s; const name = s.replace(/^#/, '').toLowerCase(); let list = await loadChannelList(); @@ -461,14 +562,15 @@ module.exports = { return text; } - function normalize(m, parentTs = '') { + 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 ${channel ? '#' + channel.name : 'the channel'}`; - else if (m.subtype === 'channel_leave' || m.subtype === 'group_leave') text = `${name} left ${channel ? '#' + channel.name : 'the channel'}`; + 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 out = { id: String(m.ts), ts: String(m.ts), @@ -485,13 +587,23 @@ module.exports = { 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) : []; + 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)`; @@ -503,57 +615,89 @@ module.exports = { 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, sending. */ 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 list = [...pollers.values()]; + if (!list.length) return { status: 'error', message: `${who} · pick channels in Admin → Slack → Channels` }; + const names = list.map(labelOf).join(', '); const sending = !sendEnabled ? 'sending off' : !pinOk ? 'sending on — set a 4–8 digit PIN' : 'sending on'; - 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` }; + const problems = [...new Set(list.filter((ch) => ch.error).map((ch) => (ch.error.includes(labelOf(ch)) ? ch.error : `${labelOf(ch)}: ${ch.error}`)))]; + if (problems.length) return { status: 'error', message: `${who} · ${names} · ${problems.join(' · ')} · ${sending}` }; + if (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}` }; } - return { status: 'ok', message: `${who} · #${channel.name} · ${sending}` }; + const cadence = intervalMs > pollMs ? ` · every ${intervalMs / 1000} s (rate budget)` : ''; + return { status: 'ok', message: `${who} · ${names}${cadence} · ${sending}` }; + } + + 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() { - 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 = { + 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(), - channel: channel ? { id: channel.id, name: channel.name, isMember: channel.isMember } : (channelId ? { id: channelId, name: lookupChannel(channelId), isMember: null } : null), - messages, + channelOrder: [...pollers.keys()], + channels, includeReplies, historyCount, + pollSeconds: intervalMs / 1000, 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. */ + /** Broadcast the snapshot when anything a tile shows has changed (ages don't count). */ function broadcastIfChanged() { - invalidate(); const snap = snapshot(); - const { lastPollAt: _ignored, ...statusSig } = snap.status; - const sig = JSON.stringify({ ...snap, status: statusSig }); + const sig = JSON.stringify(snap, (key, value) => (key === 'pollAge' || key === 'lastPollAt' ? undefined : value)); if (sig === lastSig) return; lastSig = sig; if (!streams.size) return; @@ -567,30 +711,49 @@ module.exports = { } } - /* ── 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?.(); - } - + /* ── 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 || ''); @@ -613,100 +776,141 @@ module.exports = { } 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' : ''}`); } - 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'); + /** 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 readHistory() { - const data = await slackCall(token, 'conversations.history', { channel: channelId, limit: historyCount }); + 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) : []; - rawWindow = messages; - lastPollAt = Date.now(); - pollError = ''; + 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 [...threads.keys()]) if (!inWindow.has(ts)) threads.delete(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 = threads.get(String(m.ts)); + 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, THREAD_FETCHES_PER_POLL); + .slice(0, threadFetchesPerPoll); for (const parent of stale) { try { - const rep = await slackCall(token, 'conversations.replies', { channel: channelId, ts: parent.ts, limit: THREAD_REPLIES_LIMIT }); + 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)); - threads.set(String(parent.ts), { latestReply: String(parent.latest_reply || ''), replyCount: Number(parent.reply_count), replies }); + 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(`thread ${parent.ts}: ${describeError(err, { name: identity?.name, channel: channel?.name })}`); + log(`${labelOf(ch)} thread ${parent.ts}: ${describeError(err, { name: identity?.name, channel: ch.name })}`); } } } } - async function tick() { - if (stopped || polling) return; - polling = true; - pollAgain = false; - let next = watched() ? pollMs : Math.max(pollMs, IDLE_POLL_S * 1000); + /** 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 { - if (!identity) await checkAuth(); - if (!channelId) { - next = RETRY_CONFIG_MS; + const wait = rateLimitedUntil - Date.now(); + if (wait > 0) { + retry = wait + 100 + ch.index * 100; } else { - if (!channel || channelError) await checkChannel(); - await readHistory(); + await ensureAuth(); + 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) { - 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); + retry = failed(ch, err); } finally { - polling = false; + ch.polling = false; + if (ch.settleFirst) { + ch.settleFirst(); + ch.settleFirst = null; + } broadcastIfChanged(); - if (pollAgain) pollSoon(); - else schedule(next); + 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?.(); }), + ]); + } + /* ── unlock tokens ── */ function sweepUnlocks() { const now = Date.now(); @@ -744,6 +948,22 @@ module.exports = { return u; } + /** + * The channel a browser asked to post to. Only a configured channel id is + * accepted — the selection is a choice among the admin's channels, never + * a way to reach some other one. Nothing asked for → the first configured. + */ + function chooseTarget(requested) { + const s = String(requested ?? '').trim(); + if (s) { + if (pollers.has(s)) return { id: s }; + const name = lookupChannel(s); + return { error: `${name ? `#${name}` : s} isn't one of the configured channels — pick it in Admin → Slack → Channels` }; + } + if (!channelIds.length) return { error: 'No channel configured — pick channels in Admin → Slack → Channels' }; + return { id: channelIds[0] }; + } + async function postMessage(targetChannelId, text) { const params = { channel: targetChannelId, text }; if (identity?.kind === 'bot' && sendAs) { @@ -753,8 +973,12 @@ module.exports = { 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 }) { + /** + * Post on behalf of an unlock; answers the response. `targetId` is an + * already-validated configured channel; `presetChannel` (a quick reply's + * own "| #channel") overrides it. + */ + async function deliver(req, res, unlock, { text, targetId, presetChannel = '', what }) { const now = Date.now(); if (now - unlock.lastSendAt < SEND_MIN_INTERVAL_MS) { return sendJson(res, 429, { error: 'One message per second — try again', code: 'too_fast' }); @@ -763,16 +987,16 @@ module.exports = { if (!identity || authError) { return sendJson(res, 502, { error: authError || 'Slack token not checked yet — try again in a moment' }); } - let target = channelId; - if (channelRef) { + let target = targetId || ''; + if (presetChannel) { try { - target = await resolveChannel(channelRef); + target = await resolveChannelRef(presetChannel); } catch (err) { - return sendJson(res, 502, { error: describeError(err, { name: identity.name, channel: channel?.name }) }); + return sendJson(res, 502, { error: describeError(err, { name: identity.name, channel: presetChannel }) }); } - 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 named #${String(presetChannel).replace(/^#/, '')} that ${identity.name} can see` }); } - if (!target) return sendJson(res, 400, { error: 'No channel — pick one in Admin → Slack → Channel' }); + if (!target) return sendJson(res, 400, { error: 'No channel configured — pick channels in Admin → Slack → Channels' }); const targetName = lookupChannel(target) || target; try { const data = await postMessage(target, text); @@ -780,11 +1004,12 @@ module.exports = { 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(); + if (pollers.has(target)) pollSoon(pollers.get(target)); return sendJson(res, 200, { ok: true, ts: String(data.ts || ''), channel: `#${targetName}`, + channelId: target, sendingAs: sendingAs(), expiresAt: u ? u.expiresAt : unlock.expiresAt, }); @@ -812,7 +1037,15 @@ module.exports = { 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); + 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 = { @@ -823,17 +1056,25 @@ module.exports = { sendGate, deliver, unlockFor, + chooseTarget, + namesReady, touch() { lastInterest = Date.now(); }, wake() { lastInterest = Date.now(); - if (identity && channel && Date.now() - lastPollAt > pollMs) pollSoon(); + 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, - channelId, + channelIds, + channelName: (id) => lookupChannel(id), pinOk, sendEnabled, identityName: () => identity?.name || '', - channelName: () => channel?.name || '', + sendingAs, unlock(req, res, given) { if (!sendEnabled) return sendJson(res, 403, { error: 'Sending is off — turn it on in Admin → Slack → Sending', code: 'sending_off' }); if (!pinOk) return sendJson(res, 403, { error: 'Set a PIN in Admin → Slack → Sending', code: 'no_pin' }); @@ -861,7 +1102,7 @@ module.exports = { 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}` : '' }); + return sendJson(res, 200, { token: t, expiresAt, idleMs, sendingAs: sendingAs() }); }, lock(req, res) { const t = bearer(req); @@ -873,7 +1114,13 @@ module.exports = { return { stop() { stopped = true; - clearTimeout(pollTimer); + for (const ch of pollers.values()) { + clearTimeout(ch.timer); + if (ch.settleFirst) { + ch.settleFirst(); + ch.settleFirst = null; + } + } clearInterval(heartbeat); clearInterval(sweeper); unlocks.clear(); // an admin Apply locks every send tile @@ -887,15 +1134,46 @@ module.exports = { }; }, - 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' } }, - ]; + /** + * The picker: one transcript per configured channel, titled with the + * channel's name (the shell makes the entry's name the tile's title), then + * the two send surfaces, one of each per dashboard. Between init() and a + * poller's first answer a name may still be unknown — "#" then. + */ + async tiles({ config }) { + const c = current; + const ids = c ? c.channelIds : configuredChannels(config || {}); + if (c) await c.namesReady(); + const who = c?.sendingAs() ? ` as ${c.sendingAs()}` : ''; + const entries = ids.map((id) => ({ + 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 }, + })); + entries.push( + { + id: 'send', + name: 'Send message', + description: `PIN-protected message to the transcript clicked last${who}`, + single: true, + defaultSize: { w: 3, h: 3 }, + minSize: { w: 2, h: 2 }, + settings: { view: 'send' }, + }, + { + id: 'quick', + name: 'Quick replies', + description: `PIN-protected preset buttons${who}`, + single: true, + defaultSize: { w: 3, h: 2 }, + minSize: { w: 2, h: 1 }, + settings: { view: 'quick' }, + }, + ); + return entries; }, routes() { @@ -917,7 +1195,7 @@ module.exports = { }); 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`); + 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`); @@ -929,7 +1207,7 @@ module.exports = { req.on('close', () => current && current.streams.delete(res)); }, - // The admin "Channel" select: channels the account is a member of. + // The admin "Channels" multiselect: channels the account is a member of. 'GET /channels': guard(async (req, res) => { if (!current) return unmounted(res); try { @@ -939,7 +1217,7 @@ module.exports = { .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. + // 200 with no options: the admin page keeps the saved values checked. sendJson(res, 200, { options: [], error: describeError(err, { name: current.identityName() }) }); } }), @@ -984,7 +1262,9 @@ module.exports = { const text = String(body.text ?? '').replace(/\r\n/g, '\n').trim(); if (!text) return sendJson(res, 400, { error: 'Nothing to send' }); if (text.length > MAX_TEXT_CHARS) return sendJson(res, 400, { error: `Too long — ${MAX_TEXT_CHARS} characters at most` }); - await current.deliver(req, res, unlock, { text, channelRef: body.channel ? String(body.channel) : '', what: 'sent' }); + const target = current.chooseTarget(body.channel); + if (target.error) return sendJson(res, 400, { error: target.error, code: 'bad_channel' }); + await current.deliver(req, res, unlock, { text, targetId: target.id, what: 'sent' }); }), 'POST /quick': guard(async (req, res) => { @@ -1000,7 +1280,16 @@ module.exports = { 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}"` }); + // A channel the browser names is checked even when the preset's own + // binding is about to override it — an unconfigured id is never accepted. + const target = current.chooseTarget(body.channel); + if (target.error && (body.channel || !preset.channel)) return sendJson(res, 400, { error: target.error, code: 'bad_channel' }); + await current.deliver(req, res, unlock, { + text: preset.message, + targetId: target.id || '', + presetChannel: preset.channel, + what: `quick reply "${preset.label}"`, + }); }), }; }, diff --git a/modules/slack/style.css b/modules/slack/style.css index aa61b0d..2cf6624 100644 --- a/modules/slack/style.css +++ b/modules/slack/style.css @@ -30,6 +30,29 @@ border-bottom-color: color-mix(in srgb, var(--danger) 35%, var(--border)); } +/* ── the send target ── */ + +/* A click anywhere in a transcript's body makes its channel the one Send + message and Quick replies post to. The chosen one carries a 3px accent + stripe along the bottom edge of the tile body — drawn just inside our + root, since the tile chrome around it belongs to the shell. The others + hint at it faintly on hover. */ +[data-module="slack"] .sl-transcript::after { + content: ""; + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 3px; + background: var(--accent); + opacity: 0; + pointer-events: none; + z-index: 3; + transition: opacity 0.15s ease; +} +[data-module="slack"] .sl-transcript:hover::after { opacity: 0.3; } +[data-module="slack"] .sl-transcript.is-target::after { opacity: 1; } + /* ── transcript stream ── */ [data-module="slack"] .sl-stream { diff --git a/tools/slack-mock.js b/tools/slack-mock.js index 07c3908..e61871c 100644 --- a/tools/slack-mock.js +++ b/tools/slack-mock.js @@ -26,7 +26,15 @@ * #production is seeded with mrkdwn, mentions, a link, a thread, a bot * attachment and a file; it gains a new fake message every ~8 s, edits one * every ~25 s, deletes one every ~40 s and adds a thread reply every ~30 s. - * Every request is logged. + * #ops gets a quieter trickle (every ~20 s), so a dashboard with both + * transcripts shows two channels moving at their own pace — and a + * chat.postMessage to either lands in that channel's history, which is how + * a send to the *selected* transcript can be watched arriving in the right + * tile. Every request is logged. + * + * Multi-channel dev flow: run this, then ProdDash with SLACK_API_BASE set, + * paste xoxp-anything in /admin → Slack, tick #production and #ops under + * Channels, Apply, and add both transcripts plus Send message from +. */ 'use strict'; @@ -116,6 +124,14 @@ const CHATTER = [ ]; let chatterIx = 0; +const OPS_CHATTER = [ + { user: 'U005', text: 'Ops: parking team is short two people, sending greeters over' }, + { user: 'U002', text: 'Cafe is out of oat milk again :sweat_smile:' }, + { user: 'U005', text: 'Second-service headcount: 412 in the room, 38 online' }, + { user: 'U002', text: 'Facilities: the lobby HVAC is back, thanks Dan' }, +]; +let opsIx = 0; + function tickNew() { const line = CHATTER[chatterIx % CHATTER.length]; chatterIx += 1; @@ -123,6 +139,13 @@ function tickNew() { console.log(`[slack-mock] + new message ${m.ts} from ${line.user}`); } +function tickOps() { + const line = OPS_CHATTER[opsIx % OPS_CHATTER.length]; + opsIx += 1; + const m = push('C002', { user: line.user, text: line.text }); + console.log(`[slack-mock] + new message ${m.ts} in #ops from ${line.user}`); +} + function tickEdit() { const list = history.get('C001'); const recent = list.slice(-8).filter((m) => m.user && !m.subtype && !m.edited); @@ -154,6 +177,7 @@ function tickReply() { } setInterval(tickNew, 8000).unref(); +setInterval(tickOps, 20000).unref(); setInterval(tickEdit, 25000).unref(); setInterval(tickDelete, 40000).unref(); setInterval(tickReply, 30000).unref();