Slack module: channel transcript + PIN-gated sending as the location

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

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

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

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Waters Church
2026-09-14 11:49:40 -04:00
co-authored by Claude Fable 5.1
parent f0fbf8a8f6
commit babe8263eb
9 changed files with 2637 additions and 1 deletions
+765
View File
@@ -0,0 +1,765 @@
/* 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).
Nothing here uses innerHTML with data — every string from Slack lands in
a text node. */
/* ── shared helpers ─────────────────────────────────────────────────── */
function el(tag, cls, text) {
const node = document.createElement(tag);
if (cls) node.className = cls;
if (text !== undefined) node.textContent = text;
return node;
}
function fmtTime(ms) {
const d = new Date(ms);
return isNaN(d) ? '' : d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hourCycle: 'h23' });
}
/* Inline marks the server left in the text: ```code``` `code` *bold*
_italic_ ~strike~. Openers must not sit inside a word (snake_case_names
and 5 * 3 * 2 stay as they are). Built with DOM nodes, so any character
in a message is safe. */
const MARK_RE = /(```[\s\S]*?```|`[^`\n]+`|(?<![\w*])\*(?!\s)[^*\n]*?(?<!\s)\*(?![\w*])|(?<![\w_])_(?!\s)[^_\n]*?(?<!\s)_(?![\w_])|(?<![\w~])~(?!\s)[^~\n]*?(?<!\s)~(?![\w~]))/g;
function renderRich(container, text, depth = 0) {
container.textContent = '';
const s = String(text || '');
let last = 0;
if (depth < 3) {
for (const m of s.matchAll(MARK_RE)) {
if (m.index > last) container.appendChild(document.createTextNode(s.slice(last, m.index)));
const tok = m[0];
let node;
if (tok.startsWith('```')) {
node = el('span', 'sl-code sl-block', tok.slice(3, -3).replace(/^\n+|\n+$/g, ''));
} else if (tok.startsWith('`')) {
node = el('code', 'sl-code', tok.slice(1, -1));
} else {
node = el(tok.startsWith('*') ? 'b' : tok.startsWith('_') ? 'i' : 's');
renderRich(node, tok.slice(1, -1), depth + 1);
}
container.appendChild(node);
last = m.index + tok.length;
}
}
if (last < s.length) container.appendChild(document.createTextNode(s.slice(last)));
}
function textSizePx(moduleApi, fallback = 14) {
return Math.min(48, Math.max(10, Number(moduleApi.instanceSettings.textSize) || fallback));
}
async function readJson(res) {
try {
return await res.json();
} catch {
return {};
}
}
const LOCK_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg>';
const JUMP_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 4v11"/><path d="m7 10 5 5 5-5"/><path d="M5 20h14"/></svg>';
const BACK_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 6H8l-5 6 5 6h13a1 1 0 0 0 1-1V7a1 1 0 0 0-1-1z"/><path d="m18 9-6 6M12 9l6 6"/></svg>';
const CHECK_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m5 12 5 5L20 7"/></svg>';
/* ── the factory ────────────────────────────────────────────────────── */
export default function create({ root, moduleApi }) {
const view = String(moduleApi.variant || moduleApi.instanceSettings.view || 'transcript');
if (view === 'send' || view === 'quick') return createSendTile({ root, moduleApi, mode: view });
return createTranscriptTile({ root, moduleApi });
}
/* ── transcript ─────────────────────────────────────────────────────── */
function createTranscriptTile({ root, moduleApi }) {
const entries = new Map(); // ts -> { el, sig, ... }
let pinnedToLatest = true;
let painted = false;
let stream = null;
let stopped = false;
root.innerHTML = `
<div class="sl-wrap sl-transcript">
<div class="sl-banner" hidden></div>
<div class="sl-stream">
<div class="sl-empty">Connecting to Slack…</div>
<div class="sl-entries"></div>
</div>
<button class="sl-jump" hidden>↓ New messages</button>
</div>`;
const wrap = root.querySelector('.sl-wrap');
const banner = root.querySelector('.sl-banner');
const streamEl = root.querySelector('.sl-stream');
const entriesEl = root.querySelector('.sl-entries');
const emptyEl = root.querySelector('.sl-empty');
const jumpBtn = root.querySelector('.sl-jump');
/* title-bar controls */
function bumpTextSize(delta) {
moduleApi.saveInstanceSettings({ textSize: Math.min(48, Math.max(10, textSizePx(moduleApi) + delta)) });
applyInstanceSettings();
}
moduleApi.header.addButton({ label: 'A−', title: 'Smaller text', onClick: () => bumpTextSize(-1) });
moduleApi.header.addButton({ label: 'A+', title: 'Larger text', onClick: () => bumpTextSize(1) });
moduleApi.header.addButton({
icon: JUMP_SVG,
title: 'Jump to latest',
onClick() {
pinnedToLatest = true;
jumpBtn.hidden = true;
scrollToLatest();
},
});
function applyInstanceSettings() {
const s = moduleApi.instanceSettings;
wrap.style.setProperty('--sl-size', textSizePx(moduleApi) + 'px');
wrap.classList.toggle('show-times', s.showTimes !== false);
wrap.classList.toggle('show-names', s.showNames !== false);
wrap.classList.toggle('show-system', s.showSystem !== false);
if (pinnedToLatest) scrollToLatest();
}
applyInstanceSettings();
/* scrolling */
streamEl.addEventListener('scroll', () => {
const near = streamEl.scrollHeight - streamEl.scrollTop - streamEl.clientHeight < 60;
pinnedToLatest = near;
if (near) jumpBtn.hidden = true;
});
jumpBtn.addEventListener('click', () => {
pinnedToLatest = true;
jumpBtn.hidden = true;
scrollToLatest();
});
function scrollToLatest() {
streamEl.scrollTop = streamEl.scrollHeight;
}
/* entries */
function buildMeta(m) {
const meta = el('div', 'sl-meta');
meta.append(el('span', 'sl-name', m.name || ''), el('span', 'sl-time', fmtTime(m.time)));
return meta;
}
function buildLine(m) {
// A message or a thread reply: name + time, then the text.
const line = el('div', m.parent ? 'sl-reply' : 'sl-msg');
line.classList.toggle('sl-me', Boolean(m.me));
line.classList.toggle('sl-bot', Boolean(m.bot));
line.appendChild(buildMeta(m));
const text = el('div', 'sl-text');
renderRich(text, m.text);
if (m.edited) text.appendChild(el('span', 'sl-edited', ' (edited)'));
line.appendChild(text);
return line;
}
function buildEntry(m) {
const node = el('div', 'sl-entry');
node.dataset.ts = m.ts;
return { el: node, sig: '' };
}
function fillEntry(entry, m) {
const sig = JSON.stringify(m);
if (sig === entry.sig) return;
entry.sig = sig;
const node = entry.el;
node.textContent = '';
node.classList.toggle('sl-system', Boolean(m.system));
node.classList.toggle('sl-me', Boolean(m.me));
if (m.system) {
const line = el('div', 'sl-sysline');
line.append(el('span', 'sl-time', fmtTime(m.time)), el('span', 'sl-systext', m.text || ''));
node.appendChild(line);
return;
}
node.appendChild(buildLine(m));
if (Array.isArray(m.replies)) {
if (m.replies.length) {
const list = el('div', 'sl-replies');
for (const r of m.replies) list.appendChild(buildLine(r));
node.appendChild(list);
}
} else if (m.replyCount > 0) {
node.appendChild(el('div', 'sl-replycount', `${m.replyCount} ${m.replyCount === 1 ? 'reply' : 'replies'}`));
}
}
function renderMessages(list) {
const seen = new Set();
let prev = null;
let added = 0;
for (const m of list) {
if (!m || !m.ts) continue;
seen.add(m.ts);
let entry = entries.get(m.ts);
if (!entry) {
entry = buildEntry(m);
entries.set(m.ts, entry);
if (painted) entry.el.classList.add('sl-new');
added += 1;
}
fillEntry(entry, m);
const node = entry.el;
// Keep DOM order equal to list order without re-inserting settled nodes
// (a move would replay the fade-in).
if (prev ? node.previousElementSibling !== prev : node !== entriesEl.firstElementChild) {
if (prev) prev.after(node);
else entriesEl.prepend(node);
}
prev = node;
}
for (const [ts, entry] of entries) {
if (seen.has(ts)) continue;
entry.el.remove();
entries.delete(ts);
}
if (pinnedToLatest) scrollToLatest();
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 : []);
const hasEntries = entries.size > 0;
if (st.state === 'ok') {
moduleApi.setStatus('ok', `Live — ${chan}${who}`);
banner.hidden = true;
} else {
moduleApi.setStatus(st.state === 'error' ? 'error' : 'connecting', st.message || 'Connecting to Slack…');
// With messages on screen the problem rides in a banner; with none,
// the empty state says it, once.
banner.textContent = st.message || 'Connecting to Slack…';
banner.classList.toggle('is-error', st.state === 'error');
banner.hidden = !hasEntries;
}
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.classList.toggle('is-error', st.state === 'error');
}
painted = true;
}
async function loadState() {
try {
const res = await moduleApi.fetch('/state');
if (!res.ok) throw new Error(String(res.status));
applyState(await res.json());
} catch {
/* the stream's next frame paints; the error handler reports the outage */
}
}
function connect() {
stream = moduleApi.sse('/stream', {
open() {
// Tiles share one stream; a late joiner missed its first frame, and a
// reconnect may have missed changes — a snapshot covers both.
loadState();
},
error() {
moduleApi.setStatus('error', 'ProdDash Slack module unreachable — reconnecting…');
banner.textContent = 'ProdDash Slack module unreachable — reconnecting…';
banner.classList.add('is-error');
banner.hidden = entries.size === 0;
if (!entries.size) {
emptyEl.textContent = 'ProdDash Slack module unreachable — reconnecting…';
emptyEl.classList.add('is-error');
}
},
events: {
state(e) {
try {
applyState(JSON.parse(e.data));
} catch { /* a bad frame is skipped */ }
},
},
});
}
return {
start() {
moduleApi.setStatus('connecting', 'Connecting to Slack…');
connect();
},
stop() {
stopped = true;
stream?.close();
root.innerHTML = ''; // header controls are removed by the shell
},
onResize() {
if (pinnedToLatest) scrollToLatest();
},
};
}
/* ── send + quick replies ───────────────────────────────────────────── */
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 stream = null;
let ticker = null;
let stopped = false;
let busy = false;
let pinBuf = '';
let gateNote = ''; // one line under the pad ("Locked after inactivity")
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;
root.innerHTML = `
<div class="sl-wrap sl-send" tabindex="0">
<div class="sl-gate"></div>
<div class="sl-compose" hidden></div>
</div>`;
const wrap = root.querySelector('.sl-wrap');
const gate = root.querySelector('.sl-gate');
const compose = root.querySelector('.sl-compose');
const lockBtn = moduleApi.header.addButton({ icon: LOCK_SVG, title: 'Lock now', onClick: () => lock('Locked') });
lockBtn.style.display = 'none';
function applyInstanceSettings() {
wrap.style.setProperty('--sl-size', textSizePx(moduleApi) + 'px');
}
applyInstanceSettings();
function sendingLine() {
const who = state?.sendingAs || '…';
const chan = state?.channel?.name ? '#' + state.channel.name : 'no channel';
return `Sending as ${who} → ${chan}`;
}
function authHeaders(extra = {}) {
return { Authorization: `Bearer ${token}`, ...extra };
}
/* ── the gate: a PIN pad, or the reason there isn't one ── */
function renderGate() {
compose.hidden = true;
gate.hidden = false;
lockBtn.style.display = 'none';
let kind;
if (!state) kind = 'connecting';
else if (!state.sending?.enabled) kind = 'off';
else if (!state.sending?.pinSet) kind = 'nopin';
else kind = 'pad';
if (kind !== gateKind) {
gateKind = kind;
gate.textContent = '';
if (kind === 'pad') gate.appendChild(buildPad());
else {
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';
gate.appendChild(note);
}
}
if (kind === 'pad') refreshPad();
}
let padEls = null;
function buildPad() {
const pad = el('div', 'sl-pad');
const head = el('div', 'sl-pad-head');
head.append(el('b', '', 'Enter PIN'), el('span', 'sl-pad-sub'));
const dots = el('div', 'sl-dots');
for (let i = 0; i < 8; i += 1) dots.appendChild(el('span', 'sl-dot'));
const keys = el('div', 'sl-keys');
const key = (label, cls, onClick, html) => {
const b = el('button', 'sl-key' + (cls ? ' ' + cls : ''));
b.type = 'button';
if (html) b.innerHTML = html; // our own SVG constants only
else b.textContent = label;
b.addEventListener('click', (e) => {
e.preventDefault();
onClick();
wrap.focus({ preventScroll: true });
});
return b;
};
for (const d of ['1', '2', '3', '4', '5', '6', '7', '8', '9']) keys.appendChild(key(d, '', () => pressDigit(d)));
const back = key('⌫', 'sl-key-back', () => { pinBuf = pinBuf.slice(0, -1); gateError = ''; refreshPad(); }, BACK_SVG);
back.title = 'Backspace';
keys.appendChild(back);
keys.appendChild(key('0', '', () => pressDigit('0')));
const ok = key('Unlock', 'sl-key-ok', submitPin, CHECK_SVG);
ok.title = 'Unlock';
keys.appendChild(ok);
const msg = el('div', 'sl-pad-msg');
pad.append(head, dots, keys, msg);
padEls = { pad, sub: head.lastChild, dots, msg, ok };
return pad;
}
function refreshPad() {
if (!padEls) return;
padEls.sub.textContent = ` · ${sendingLine().replace(/^Sending/, 'sending')}`;
const n = Math.min(8, pinBuf.length);
[...padEls.dots.children].forEach((d, i) => {
d.classList.toggle('on', i < n);
// Show as many slots as could still be typed: 4 minimum, up to 8.
d.hidden = i >= Math.max(4, n + (n < 8 ? 1 : 0));
});
padEls.ok.disabled = pinBuf.length < 4 || busy;
padEls.msg.textContent = gateError || gateNote;
padEls.msg.classList.toggle('is-error', Boolean(gateError));
}
function pressDigit(d) {
if (busy || pinBuf.length >= 8) return;
pinBuf += d;
gateError = '';
refreshPad();
if (pinBuf.length === 8) submitPin();
}
function shake(message) {
gateError = message;
gateNote = '';
pinBuf = '';
refreshPad();
if (!padEls) return;
padEls.pad.classList.remove('sl-shake');
// restart the animation
void padEls.pad.offsetWidth;
padEls.pad.classList.add('sl-shake');
}
async function submitPin() {
if (busy || pinBuf.length < 4) return;
busy = true;
refreshPad();
try {
const res = await moduleApi.fetch('/unlock', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pin: pinBuf }),
});
const body = await readJson(res);
if (!res.ok || !body.token) {
shake(body.error || `Could not unlock (HTTP ${res.status})`);
return;
}
token = String(body.token);
expiresAt = Number(body.expiresAt) || Date.now() + 10 * 60000;
pinBuf = '';
gateError = '';
gateNote = '';
await showCompose();
} catch {
shake('ProdDash unreachable — try again');
} finally {
busy = false;
refreshPad();
}
}
wrap.addEventListener('keydown', (e) => {
if (!gate.hidden && gateKind === 'pad') {
if (/^[0-9]$/.test(e.key)) { e.preventDefault(); pressDigit(e.key); }
else if (e.key === 'Backspace') { e.preventDefault(); pinBuf = pinBuf.slice(0, -1); gateError = ''; refreshPad(); }
else if (e.key === 'Enter') { e.preventDefault(); submitPin(); }
else if (e.key === 'Escape') { pinBuf = ''; gateError = ''; refreshPad(); }
}
});
/* ── unlocked: compose or the preset grid ── */
let composeEls = null;
function buildCompose() {
compose.textContent = '';
const head = el('div', 'sl-compose-head');
const line = el('span', 'sl-sending', sendingLine());
const lockLink = el('button', 'sl-lock-link', 'Lock');
lockLink.type = 'button';
lockLink.title = 'Lock now';
lockLink.addEventListener('click', () => lock('Locked'));
head.append(line, lockLink);
compose.appendChild(head);
const foot = el('div', 'sl-compose-foot');
const msg = el('div', 'sl-msg');
const countdown = el('span', 'sl-countdown');
countdown.hidden = true;
let input = null;
let sendBtn = null;
let grid = null;
if (mode === 'send') {
input = el('textarea', 'sl-input');
input.rows = 3;
input.placeholder = state?.channel?.name ? `Message #${state.channel.name}…` : 'Message…';
input.spellcheck = true;
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendText();
}
});
compose.appendChild(input);
sendBtn = el('button', 'sl-sendbtn', 'Send');
sendBtn.type = 'button';
sendBtn.addEventListener('click', sendText);
foot.append(msg, countdown, sendBtn);
} else {
grid = el('div', 'sl-grid');
compose.appendChild(grid);
foot.append(msg, countdown);
}
compose.appendChild(foot);
composeEls = { line, msg, countdown, input, sendBtn, grid };
}
async function showCompose() {
buildCompose();
gate.hidden = true;
compose.hidden = false;
lockBtn.style.display = '';
moduleApi.setStatus(statusState(), `${statusMessage()} · unlocked`);
if (mode === 'quick') await loadPresets();
else composeEls.input?.focus();
tickLock();
}
function flash(text) {
if (!composeEls) return;
composeEls.msg.textContent = text;
composeEls.msg.className = 'sl-msg is-flash';
clearTimeout(flash.timer);
flash.timer = setTimeout(() => {
if (composeEls && composeEls.msg.textContent === text) {
composeEls.msg.textContent = '';
composeEls.msg.className = 'sl-msg';
}
}, 1800);
}
function showError(text) {
if (!composeEls) return;
clearTimeout(flash.timer);
composeEls.msg.textContent = text;
composeEls.msg.className = 'sl-msg is-error';
}
async function sendText() {
if (busy || !composeEls?.input) return;
const text = composeEls.input.value.trim();
if (!text) return;
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 }),
});
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 ✓');
} catch {
showError('ProdDash unreachable — not sent');
} finally {
busy = false;
if (composeEls?.sendBtn) {
composeEls.sendBtn.disabled = false;
composeEls.input?.focus();
}
}
}
async function loadPresets() {
if (!composeEls?.grid) return;
composeEls.grid.textContent = '';
composeEls.grid.appendChild(el('div', 'sl-note', 'Loading…'));
try {
const res = await moduleApi.fetch('/quick', { headers: authHeaders() });
const body = await readJson(res);
if (res.status === 401) return void lock(body.error || 'Locked — enter the PIN', true);
if (!res.ok) throw new Error(body.error || `HTTP ${res.status}`);
presets = Array.isArray(body.presets) ? body.presets : [];
if (body.expiresAt) expiresAt = Number(body.expiresAt);
renderPresets();
} catch (err) {
composeEls.grid.textContent = '';
const note = el('div', 'sl-note is-error', `Could not load quick replies — ${err.message}`);
composeEls.grid.appendChild(note);
}
}
function renderPresets() {
if (!composeEls?.grid) return;
const grid = composeEls.grid;
grid.textContent = '';
if (!presets.length) {
grid.appendChild(el('div', 'sl-note', 'No quick replies yet — add some in Admin → Slack → Sending'));
return;
}
for (const p of presets) {
const b = el('button', 'sl-quick');
b.type = 'button';
b.appendChild(el('span', 'sl-quick-label', p.label));
if (p.channel) b.appendChild(el('span', 'sl-quick-chan', `→ ${p.channel}`));
b.addEventListener('click', () => sendQuick(p.index, b));
grid.appendChild(b);
}
}
async function sendQuick(index, btn) {
if (busy) return;
busy = true;
btn.disabled = true;
btn.classList.add('is-busy');
try {
const res = await moduleApi.fetch('/quick', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ index }),
});
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})`);
if (body.expiresAt) expiresAt = Number(body.expiresAt);
if (composeEls) {
composeEls.msg.textContent = '';
composeEls.msg.className = 'sl-msg';
}
btn.classList.remove('is-busy');
btn.classList.add('is-sent');
const label = btn.querySelector('.sl-quick-label');
const original = label.textContent;
label.textContent = 'Sent ✓';
setTimeout(() => {
if (!btn.isConnected) return;
label.textContent = original;
btn.classList.remove('is-sent');
}, 1000);
} catch {
showError('ProdDash unreachable — not sent');
} finally {
busy = false;
btn.disabled = false;
btn.classList.remove('is-busy');
}
}
/* ── locking ── */
function lock(note, fromServer = false) {
const had = token;
token = '';
expiresAt = 0;
pinBuf = '';
presets = null;
composeEls = null;
compose.textContent = '';
if (had && !fromServer) {
moduleApi.fetch('/lock', { method: 'POST', headers: { Authorization: `Bearer ${had}` } }).catch(() => {});
}
gateNote = note || '';
gateError = '';
gateKind = ''; // rebuild the pad fresh
padEls = null;
renderGate();
moduleApi.setStatus(statusState(), `${statusMessage()} · locked`);
}
function tickLock() {
if (!token) return;
const left = expiresAt - Date.now();
if (left <= 0) return void lock('Locked after inactivity');
if (composeEls) {
const cd = composeEls.countdown;
if (left < 60000) {
const s = Math.ceil(left / 1000);
cd.textContent = `Locks in 0:${String(s).padStart(2, '0')}`;
cd.hidden = false;
} else cd.hidden = true;
}
}
/* ── server state ── */
function statusState() {
const st = state?.status?.state;
return st === 'ok' || st === 'connecting' || st === 'error' ? st : 'connecting';
}
function statusMessage() {
return state?.status?.message || 'Connecting to Slack…';
}
function applyState(s) {
if (stopped || !s || typeof s !== 'object') return;
state = s;
moduleApi.setStatus(statusState(), `${statusMessage()} · ${token ? 'unlocked' : 'locked'}`);
if (token) {
// Sending switched off or the PIN removed while unlocked: the server
// will refuse the next send; say so now instead of on failure.
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}…`;
}
} else renderGate();
}
async function loadState() {
try {
const res = await moduleApi.fetch('/state');
if (!res.ok) throw new Error(String(res.status));
applyState(await res.json());
} catch { /* the stream paints */ }
}
function connect() {
stream = moduleApi.sse('/stream', {
open() { loadState(); },
error() {
moduleApi.setStatus('error', 'ProdDash Slack module unreachable — reconnecting…');
},
events: {
state(e) {
try {
applyState(JSON.parse(e.data));
} catch { /* skip a bad frame */ }
},
},
});
}
return {
start() {
moduleApi.setStatus('connecting', 'Connecting to Slack…');
renderGate();
connect();
ticker = setInterval(tickLock, 1000);
},
stop() {
stopped = true;
clearInterval(ticker);
clearTimeout(flash.timer);
stream?.close();
token = ''; // the unlock dies with the tile
root.innerHTML = '';
},
};
}
+100
View File
@@ -0,0 +1,100 @@
{
"id": "slack",
"name": "Slack",
"version": "1.0.0",
"proddash": ">=1.4.0",
"description": "A Slack channel's live transcript, plus PIN-protected sending as this location",
"client": "client.js",
"server": "server.js",
"style": "style.css",
"minSize": { "w": 2, "h": 2 },
"defaultSize": { "w": 4, "h": 4 },
"configGroups": {
"Slack account": {
"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."
},
"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."
}
},
"configSchema": {
"token": {
"type": "password",
"label": "Token",
"default": "",
"group": "Slack account",
"help": "The location account's User OAuth Token (xoxp-…, recommended) or a Bot User OAuth Token (xoxb-…)."
},
"sendAs": {
"type": "string",
"label": "Send as",
"default": "",
"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",
"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."
},
"pollSeconds": {
"type": "number",
"label": "Refresh interval (s)",
"default": 2,
"group": "Transcript",
"help": "How often the channel is re-read (1 s at the fastest)."
},
"historyCount": {
"type": "number",
"label": "Messages kept",
"default": 40,
"group": "Transcript"
},
"includeReplies": {
"type": "switch",
"label": "Thread replies",
"default": false,
"group": "Transcript"
},
"sendEnabled": {
"type": "switch",
"label": "Sending",
"default": false,
"group": "Sending"
},
"pin": {
"type": "password",
"label": "PIN",
"default": "",
"group": "Sending",
"help": "4–8 digits; asked every time a send tile opens."
},
"pinIdleMinutes": {
"type": "number",
"label": "Auto-lock (min)",
"default": 10,
"group": "Sending"
},
"quickReplies": {
"type": "text",
"label": "Quick replies",
"default": "",
"group": "Sending",
"help": "One per line: Label | Message. Add | #channel-name to send one somewhere else."
}
},
"instanceGroups": {
"Transcript": { "collapsed": false }
},
"instanceSchema": {
"textSize": { "type": "number", "label": "Text size (px)", "default": 14 },
"showTimes": { "type": "switch", "label": "Timestamps", "default": true, "group": "Transcript" },
"showNames": { "type": "switch", "label": "Names", "default": true, "group": "Transcript" },
"showSystem": { "type": "switch", "label": "System lines", "default": true, "group": "Transcript" }
}
}
File diff suppressed because it is too large Load Diff
+424
View File
@@ -0,0 +1,424 @@
/* Slack module — scoped to its tiles, shell variables only.
--sl-size (per tile, from the Text size setting) drives every font size. */
[data-module="slack"] .sl-wrap {
--sl-size: 14px;
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
container-type: size;
font-size: var(--sl-size);
color: var(--text);
}
[data-module="slack"] .sl-wrap:focus { outline: none; }
[data-module="slack"] [hidden] { display: none !important; }
/* ── degraded banner (transcript) ── */
[data-module="slack"] .sl-banner {
flex: none;
padding: 4px 10px;
font-size: 0.8em;
color: var(--warn);
background: color-mix(in srgb, var(--warn) 12%, transparent);
border-bottom: 1px solid color-mix(in srgb, var(--warn) 35%, var(--border));
}
[data-module="slack"] .sl-banner.is-error {
color: var(--danger);
background: color-mix(in srgb, var(--danger) 12%, transparent);
border-bottom-color: color-mix(in srgb, var(--danger) 35%, var(--border));
}
/* ── transcript stream ── */
[data-module="slack"] .sl-stream {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 6px 10px 10px;
overscroll-behavior: contain;
}
[data-module="slack"] .sl-empty {
color: var(--muted);
font-size: 0.9em;
text-align: center;
padding: 14% 10px 0;
line-height: 1.45;
}
[data-module="slack"] .sl-empty.is-error { color: var(--danger); }
[data-module="slack"] .sl-entry {
padding: 4px 6px;
border-radius: 8px;
line-height: 1.4;
}
[data-module="slack"] .sl-entry:hover { background: var(--bg); }
[data-module="slack"] .sl-entry.sl-new { animation: sl-fade-in 0.25s ease-out; }
@keyframes sl-fade-in {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: none; }
}
/* a message from this machine's own account */
[data-module="slack"] .sl-entry.sl-me {
box-shadow: inset 3px 0 0 color-mix(in srgb, var(--accent) 55%, transparent);
}
[data-module="slack"] .sl-meta {
display: flex;
align-items: baseline;
gap: 8px;
min-width: 0;
margin-bottom: 1px;
}
[data-module="slack"] .sl-name {
font-weight: 700;
font-size: 0.86em;
color: var(--accent);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-module="slack"] .sl-bot .sl-name { color: var(--muted); }
[data-module="slack"] .sl-time {
flex: none;
font-size: 0.72em;
color: var(--muted);
font-variant-numeric: tabular-nums;
}
[data-module="slack"] .sl-wrap:not(.show-times) .sl-time { display: none; }
[data-module="slack"] .sl-wrap:not(.show-names) .sl-msg > .sl-meta,
[data-module="slack"] .sl-wrap:not(.show-names) .sl-reply > .sl-meta { display: none; }
[data-module="slack"] .sl-wrap.show-times:not(.show-names) .sl-msg > .sl-meta,
[data-module="slack"] .sl-wrap.show-times:not(.show-names) .sl-reply > .sl-meta { display: flex; }
[data-module="slack"] .sl-wrap.show-times:not(.show-names) .sl-name { display: none; }
[data-module="slack"] .sl-wrap:not(.show-system) .sl-entry.sl-system { display: none; }
[data-module="slack"] .sl-text {
white-space: pre-wrap;
overflow-wrap: anywhere;
color: var(--text);
}
[data-module="slack"] .sl-text b { font-weight: 700; }
[data-module="slack"] .sl-text i { font-style: italic; }
[data-module="slack"] .sl-text s { color: var(--muted); }
[data-module="slack"] .sl-code {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.9em;
padding: 0 4px;
border-radius: 4px;
background: color-mix(in srgb, var(--text) 8%, transparent);
border: 1px solid var(--border);
}
[data-module="slack"] .sl-code.sl-block {
display: block;
padding: 4px 8px;
margin: 3px 0;
white-space: pre-wrap;
}
[data-module="slack"] .sl-edited {
font-size: 0.75em;
color: var(--muted);
white-space: nowrap;
}
/* thread replies nest under their parent; without them, a muted count */
[data-module="slack"] .sl-replies {
margin: 4px 0 0 10px;
padding-left: 10px;
border-left: 2px solid var(--border);
display: flex;
flex-direction: column;
gap: 3px;
}
[data-module="slack"] .sl-reply { font-size: 0.92em; }
[data-module="slack"] .sl-replycount {
margin-top: 2px;
font-size: 0.75em;
color: var(--muted);
}
/* system lines: joins, leaves, topic changes */
[data-module="slack"] .sl-sysline {
display: flex;
align-items: baseline;
gap: 8px;
font-size: 0.82em;
color: var(--muted);
font-style: italic;
}
[data-module="slack"] .sl-sysline .sl-time { font-style: normal; }
[data-module="slack"] .sl-jump {
position: absolute;
left: 50%;
bottom: 10px;
transform: translateX(-50%);
padding: 5px 12px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--accent);
color: var(--accent-text);
font: inherit;
font-size: 0.8em;
font-weight: 700;
cursor: pointer;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35);
}
/* ── send / quick replies: the gate ── */
[data-module="slack"] .sl-gate,
[data-module="slack"] .sl-compose {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
padding: 6px 10px;
overflow: auto;
}
[data-module="slack"] .sl-note {
margin: auto;
padding: 10px;
text-align: center;
color: var(--muted);
font-size: 0.9em;
line-height: 1.45;
}
[data-module="slack"] .sl-note.is-error { color: var(--danger); }
/* The pad never shrinks below its content (min-height stays `auto` on it
and on the key grid) — a shrunken grid would spill its rows under the
message line, which then swallows the taps. A tile too short for it
scrolls instead (.sl-gate has overflow: auto). */
[data-module="slack"] .sl-pad {
flex: 1 1 auto;
display: flex;
flex-direction: column;
gap: 5px;
max-width: 340px;
width: 100%;
margin: 0 auto;
}
[data-module="slack"] .sl-pad.sl-shake { animation: sl-shake 0.4s ease; }
@keyframes sl-shake {
10%, 90% { transform: translateX(-2px); }
20%, 80% { transform: translateX(3px); }
30%, 50%, 70% { transform: translateX(-5px); }
40%, 60% { transform: translateX(5px); }
}
/* one line: "Enter PIN · sending as Apollo Beach → #production" */
[data-module="slack"] .sl-pad-head {
flex: none;
text-align: center;
font-size: 0.8em;
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-module="slack"] .sl-pad-head b { font-weight: 700; color: var(--text); }
[data-module="slack"] .sl-dots {
flex: none;
display: flex;
justify-content: center;
gap: 8px;
height: 14px;
}
[data-module="slack"] .sl-dot {
width: 11px;
height: 11px;
border-radius: 50%;
border: 2px solid var(--muted);
transition: background 0.12s, border-color 0.12s;
}
[data-module="slack"] .sl-dot.on { background: var(--accent); border-color: var(--accent); }
[data-module="slack"] .sl-keys {
flex: 1 1 auto;
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: repeat(4, minmax(24px, 1fr));
gap: 5px;
}
[data-module="slack"] .sl-key {
min-width: 0;
min-height: 24px;
border: 1px solid var(--border);
border-radius: 10px;
background: color-mix(in srgb, var(--text) 5%, var(--panel));
color: var(--text);
font: inherit;
font-size: clamp(13px, 9cqh, 26px);
font-weight: 600;
font-variant-numeric: tabular-nums;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
transition: background 0.1s;
}
[data-module="slack"] .sl-key:hover { background: var(--border); }
[data-module="slack"] .sl-key:active { background: color-mix(in srgb, var(--accent) 30%, var(--panel)); }
[data-module="slack"] .sl-key svg { width: 1.1em; height: 1.1em; }
[data-module="slack"] .sl-key-back { color: var(--muted); }
[data-module="slack"] .sl-key-ok { background: var(--accent); color: var(--accent-text); border-color: transparent; }
[data-module="slack"] .sl-key-ok:hover { background: color-mix(in srgb, var(--accent) 85%, var(--text)); }
[data-module="slack"] .sl-key-ok:disabled {
background: color-mix(in srgb, var(--accent) 25%, var(--panel));
color: var(--muted);
cursor: default;
}
[data-module="slack"] .sl-pad-msg {
flex: none;
min-height: 1.1em;
text-align: center;
font-size: 0.78em;
line-height: 1.1;
color: var(--muted);
}
[data-module="slack"] .sl-pad-msg.is-error { color: var(--danger); font-weight: 600; }
/* ── send / quick replies: unlocked ── */
[data-module="slack"] .sl-compose { gap: 6px; }
[data-module="slack"] .sl-compose-head {
flex: none;
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
font-size: 0.8em;
color: var(--muted);
}
[data-module="slack"] .sl-sending {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-module="slack"] .sl-lock-link {
flex: none;
padding: 1px 8px;
border: 1px solid var(--border);
border-radius: 999px;
background: none;
color: var(--muted);
font: inherit;
font-size: 0.9em;
cursor: pointer;
}
[data-module="slack"] .sl-lock-link:hover { color: var(--text); background: var(--border); }
[data-module="slack"] .sl-input {
flex: 1;
min-height: 2.6em;
width: 100%;
resize: none;
padding: 7px 9px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg);
color: var(--text);
font: inherit;
line-height: 1.4;
}
[data-module="slack"] .sl-input:focus { outline: none; border-color: var(--accent); }
[data-module="slack"] .sl-compose-foot {
flex: none;
display: flex;
align-items: center;
gap: 8px;
min-height: 1.6em;
}
[data-module="slack"] .sl-msg {
flex: 1;
min-width: 0;
font-size: 0.8em;
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-module="slack"] .sl-msg.is-flash { color: var(--accent); font-weight: 700; }
[data-module="slack"] .sl-msg.is-error { color: var(--danger); white-space: normal; font-weight: 600; }
[data-module="slack"] .sl-countdown {
flex: none;
font-size: 0.75em;
color: var(--warn);
font-variant-numeric: tabular-nums;
}
[data-module="slack"] .sl-sendbtn {
flex: none;
padding: 6px 16px;
border: none;
border-radius: 8px;
background: var(--accent);
color: var(--accent-text);
font: inherit;
font-weight: 700;
cursor: pointer;
}
[data-module="slack"] .sl-sendbtn:hover { background: color-mix(in srgb, var(--accent) 85%, var(--text)); }
[data-module="slack"] .sl-sendbtn:disabled { opacity: 0.6; cursor: default; }
/* the preset grid: big targets, labels wrap */
[data-module="slack"] .sl-grid {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(150px, 100%), 1fr));
grid-auto-rows: minmax(44px, auto);
gap: 6px;
align-content: start;
overflow-y: auto;
}
[data-module="slack"] .sl-grid .sl-note { grid-column: 1 / -1; }
[data-module="slack"] .sl-quick {
min-width: 0;
min-height: 44px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: 10px;
background: color-mix(in srgb, var(--accent) 12%, var(--panel));
color: var(--text);
font: inherit;
font-weight: 600;
line-height: 1.25;
text-align: center;
cursor: pointer;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
transition: background 0.1s;
}
[data-module="slack"] .sl-quick:hover { background: color-mix(in srgb, var(--accent) 22%, var(--panel)); }
[data-module="slack"] .sl-quick:active { background: color-mix(in srgb, var(--accent) 40%, var(--panel)); }
[data-module="slack"] .sl-quick.is-busy { opacity: 0.7; }
[data-module="slack"] .sl-quick.is-sent {
background: var(--accent);
border-color: transparent;
color: var(--accent-text);
}
[data-module="slack"] .sl-quick-label { overflow-wrap: anywhere; }
[data-module="slack"] .sl-quick-chan {
font-size: 0.72em;
font-weight: 500;
color: var(--muted);
}
[data-module="slack"] .sl-quick.is-sent .sl-quick-chan { color: inherit; }