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:
co-authored by
Claude Fable 5.1
parent
f0fbf8a8f6
commit
babe8263eb
@@ -37,6 +37,7 @@ title-bar controls). The rules that matter most:
|
|||||||
- Verify against the guide's testing section and resilience checklist. Mocks
|
- Verify against the guide's testing section and resilience checklist. Mocks
|
||||||
for the existing upstreams: `node tools/prodcom-mock.js` (ProdCom) and
|
for the existing upstreams: `node tools/prodcom-mock.js` (ProdCom) and
|
||||||
`node band-lineup-display/tmp/pp-mock.js 1599` (ProPresenter).
|
`node band-lineup-display/tmp/pp-mock.js 1599` (ProPresenter).
|
||||||
|
`node tools/slack-mock.js 24716` (Slack; run ProdDash with `SLACK_API_BASE=http://127.0.0.1:24716/api`).
|
||||||
|
|
||||||
## Reference code (read-only forks — don't develop these here)
|
## Reference code (read-only forks — don't develop these here)
|
||||||
|
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ the app); its settings are kept for a later reinstall.
|
|||||||
Both schemas map field names to specs:
|
Both schemas map field names to specs:
|
||||||
|
|
||||||
```
|
```
|
||||||
{ "type": "string" | "number" | "boolean" | "switch" | "select" | "color" | "password" | "endpoint",
|
{ "type": "string" | "text" | "number" | "boolean" | "switch" | "select" | "color" | "password" | "endpoint",
|
||||||
"label": "Shown next to the field",
|
"label": "Shown next to the field",
|
||||||
"default": <value>,
|
"default": <value>,
|
||||||
"group": "Section heading", // optional; see below
|
"group": "Section heading", // optional; see below
|
||||||
@@ -125,6 +125,9 @@ shown or enabled, off means hidden or disabled, and the label shouldn't say
|
|||||||
so twice.
|
so twice.
|
||||||
`color` renders a swatch picker and stores a `#rrggbb` string (give it a hex
|
`color` renders a swatch picker and stores a `#rrggbb` string (give it a hex
|
||||||
default; the tile applies it as a CSS custom property — see `pco-plan`).
|
default; the tile applies it as a CSS custom property — see `pco-plan`).
|
||||||
|
`text` is a multi-line string — a `<textarea>` in the admin page, stored with
|
||||||
|
its newlines; one item per line is the convention (the `slack` module's quick
|
||||||
|
replies). Admin config only.
|
||||||
|
|
||||||
`group` gathers consecutive fields under a labeled subsection in the admin
|
`group` gathers consecutive fields under a labeled subsection in the admin
|
||||||
page, so a module with several distinct concerns (e.g. a ProPresenter
|
page, so a module with several distinct concerns (e.g. a ProPresenter
|
||||||
|
|||||||
@@ -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 = '';
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
@@ -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; }
|
||||||
@@ -135,6 +135,22 @@ body { overflow: auto; }
|
|||||||
.module-form { margin-top: 12px; border-top: 1px solid var(--border); padding-top: 12px; }
|
.module-form { margin-top: 12px; border-top: 1px solid var(--border); padding-top: 12px; }
|
||||||
.module-form .field { max-width: 420px; }
|
.module-form .field { max-width: 420px; }
|
||||||
|
|
||||||
|
/* a multi-line string (manifest type `text`): the same box as the other
|
||||||
|
inputs (those rules live in the shell's style.css), a few lines tall */
|
||||||
|
.field textarea {
|
||||||
|
padding: 7px 9px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 7px;
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 72px;
|
||||||
|
}
|
||||||
|
.field textarea:focus { outline: none; border-color: var(--accent); }
|
||||||
|
|
||||||
/* endpoint fields: every module's upstream is entered the same way */
|
/* endpoint fields: every module's upstream is entered the same way */
|
||||||
.field .endpoint-row { display: flex; align-items: center; gap: 7px; }
|
.field .endpoint-row { display: flex; align-items: center; gap: 7px; }
|
||||||
.endpoint-row .ep-host { flex: 1; min-width: 0; }
|
.endpoint-row .ep-host { flex: 1; min-width: 0; }
|
||||||
|
|||||||
@@ -239,6 +239,17 @@ function fieldFor(key, spec, value, passwordSet, moduleId) {
|
|||||||
input.value = hex(value, hex(spec?.default, '#2ee59a'));
|
input.value = hex(value, hex(spec?.default, '#2ee59a'));
|
||||||
field.append(span, input);
|
field.append(span, input);
|
||||||
read = () => input.value;
|
read = () => input.value;
|
||||||
|
} else if (type === 'text') {
|
||||||
|
// A multi-line string — one item per line by convention (a list of
|
||||||
|
// presets, say). Stored as-is, newlines included.
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.textContent = label;
|
||||||
|
const textarea = document.createElement('textarea');
|
||||||
|
textarea.rows = 5;
|
||||||
|
textarea.spellcheck = false;
|
||||||
|
textarea.value = value === undefined || value === null ? '' : String(value);
|
||||||
|
field.append(span, textarea);
|
||||||
|
read = () => textarea.value;
|
||||||
} else {
|
} else {
|
||||||
const span = document.createElement('span');
|
const span = document.createElement('span');
|
||||||
span.textContent = label;
|
span.textContent = label;
|
||||||
|
|||||||
@@ -0,0 +1,309 @@
|
|||||||
|
/**
|
||||||
|
* Mock Slack Web API for developing the slack module without a workspace.
|
||||||
|
*
|
||||||
|
* node tools/slack-mock.js [port] (default 24716)
|
||||||
|
* SLACK_API_BASE=http://127.0.0.1:24716/api node server.js
|
||||||
|
*
|
||||||
|
* Then, in /admin → Slack, paste any token that starts with xoxp- (a user
|
||||||
|
* token: auth.test answers as the location account "Apollo Beach") or xoxb-
|
||||||
|
* (a bot token: bot_id set, user "proddash"); anything else is invalid_auth.
|
||||||
|
* Two more tokens exercise the error paths: xoxp-revoked → token_revoked,
|
||||||
|
* xoxp-noscope → missing_scope (needed channels:history) on history reads.
|
||||||
|
*
|
||||||
|
* Methods, all under /api/<method>, POST form or JSON (GET works too):
|
||||||
|
* auth.test identity for the token (see above)
|
||||||
|
* conversations.list #production, #ops, #campus-leads (private) as
|
||||||
|
* member; #announcements not a member; paginated
|
||||||
|
* conversations.info one channel, with is_member
|
||||||
|
* conversations.history newest-first window (limit); not_in_channel for
|
||||||
|
* #announcements
|
||||||
|
* conversations.replies a thread's parent + replies
|
||||||
|
* users.info display names for the seeded users
|
||||||
|
* chat.postMessage appends to the channel (echoes `username` for
|
||||||
|
* bots as a bot_message); text containing "!429"
|
||||||
|
* answers HTTP 429 with Retry-After: 3
|
||||||
|
*
|
||||||
|
* #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.
|
||||||
|
*/
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const http = require('http');
|
||||||
|
|
||||||
|
const PORT = Number.parseInt(process.argv[2] || '24716', 10);
|
||||||
|
|
||||||
|
/* ── data ───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const USERS = {
|
||||||
|
U001: { name: 'apollo.beach', real_name: 'Apollo Beach', display_name: 'Apollo Beach' },
|
||||||
|
U002: { name: 'michael', real_name: 'Michael McGary', display_name: 'Michael' },
|
||||||
|
U003: { name: 'sarah.lee', real_name: 'Sarah Lee', display_name: 'Sarah' },
|
||||||
|
U004: { name: 'north.attleboro', real_name: 'North Attleboro', display_name: 'North Attleboro' },
|
||||||
|
U005: { name: 'dan.k', real_name: 'Dan Kowalski', display_name: 'Dan K' },
|
||||||
|
UBOT: { name: 'proddash', real_name: 'ProdDash', display_name: '', is_bot: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
const CHANNELS = [
|
||||||
|
{ id: 'C001', name: 'production', is_private: false, is_member: true },
|
||||||
|
{ id: 'C002', name: 'ops', is_private: false, is_member: true },
|
||||||
|
{ id: 'G001', name: 'campus-leads', is_private: true, is_member: true },
|
||||||
|
{ id: 'C003', name: 'announcements', is_private: false, is_member: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
let tsCounter = 0;
|
||||||
|
function ts(offsetMs = 0) {
|
||||||
|
tsCounter += 1;
|
||||||
|
return `${Math.floor((Date.now() + offsetMs) / 1000)}.${String(tsCounter).padStart(6, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** channel id -> messages (ascending); replies live inside their parent. */
|
||||||
|
const history = new Map();
|
||||||
|
for (const c of CHANNELS) history.set(c.id, []);
|
||||||
|
|
||||||
|
function push(channelId, msg) {
|
||||||
|
const list = history.get(channelId);
|
||||||
|
const m = { type: 'message', ts: msg.ts || ts(), ...msg };
|
||||||
|
list.push(m);
|
||||||
|
if (list.length > 400) list.splice(0, list.length - 400);
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
function seed() {
|
||||||
|
const min = 60000;
|
||||||
|
const at = (minsAgo) => ts(-minsAgo * min);
|
||||||
|
push('C001', { ts: at(58), user: 'U003', subtype: 'channel_join', text: '<@U003> has joined the channel' });
|
||||||
|
push('C001', { ts: at(55), user: 'U002', text: 'Morning all — doors at 9:15, first service 10:00. Run of show is in <#C002|ops>.' });
|
||||||
|
push('C001', { ts: at(50), user: 'U001', text: 'Apollo Beach is up. Stream key checked, ProPresenter online :white_check_mark:' });
|
||||||
|
push('C001', { ts: at(47), user: 'U004', text: 'North Attleboro here — we have *no audio* on the sermon feed yet, working on it' });
|
||||||
|
push('C001', { ts: at(44), user: 'U002', text: '<@U004> try the Dante patch — `Sermon Feed L/R` should be on 31/32. _Ping me if not._' });
|
||||||
|
const parent = push('C001', { ts: at(40), user: 'U003', text: 'Countdown video is the new one this week, right? ~old one~ <-- not this', reply_count: 2, latest_reply: '' });
|
||||||
|
parent.replies = [
|
||||||
|
{ type: 'message', ts: at(39), user: 'U002', text: 'Yes — "Countdown 5 min (Sept)" in the media bin', thread_ts: parent.ts },
|
||||||
|
{ type: 'message', ts: at(38), user: 'U003', text: 'Got it :+1:', thread_ts: parent.ts },
|
||||||
|
];
|
||||||
|
parent.latest_reply = parent.replies[1].ts;
|
||||||
|
push('C001', {
|
||||||
|
ts: at(35), subtype: 'bot_message', bot_id: 'B002', username: 'Planning Center', text: '',
|
||||||
|
attachments: [{ fallback: 'Plan updated: Sunday Service — Sept 14 (2 items changed)', title: 'Plan updated', text: 'Sunday Service — Sept 14 (2 items changed)' }],
|
||||||
|
});
|
||||||
|
push('C001', { ts: at(30), user: 'U005', text: 'Lower thirds for the announcements: <https://waterschurch.org/plan|this week’s plan>', files: [{ id: 'F001', name: 'lower-thirds-0914.png', title: 'lower-thirds-0914.png' }] });
|
||||||
|
push('C001', { ts: at(25), user: 'U004', text: 'Audio fixed — Dante patch was it. Thanks <@U002> :pray:', edited: { user: 'U004', ts: at(24) } });
|
||||||
|
push('C001', { ts: at(20), user: 'U002', text: '<!here> 10 minutes to doors. Campuses, post "ready" when your pre-service loop is running.' });
|
||||||
|
push('C001', { ts: at(15), user: 'U001', text: 'Apollo Beach ready' });
|
||||||
|
push('C001', { ts: at(14), user: 'U004', text: 'North Attleboro ready' });
|
||||||
|
|
||||||
|
push('C002', { ts: at(120), user: 'U002', text: 'Run of show for Sept 14 is pinned. Changes go here, not in DMs.' });
|
||||||
|
push('C002', { ts: at(90), user: 'U005', text: 'Bumper video is 0:42 this week, not 0:30 — adjust your timers.' });
|
||||||
|
push('G001', { ts: at(200), user: 'U002', text: 'Leads: budget call moved to Tuesday 2pm.' });
|
||||||
|
push('C003', { ts: at(300), user: 'U002', text: 'Staff meeting notes are in the drive.' });
|
||||||
|
}
|
||||||
|
seed();
|
||||||
|
|
||||||
|
const CHATTER = [
|
||||||
|
{ user: 'U003', text: 'Walk-in music is a touch loud at the back, can we come down 2 dB?' },
|
||||||
|
{ user: 'U002', text: 'Cue 4 is the *baptism video* — heads up, it has its own audio' },
|
||||||
|
{ user: 'U005', text: 'Livestream is live, 38 viewers already :tada:' },
|
||||||
|
{ user: 'U004', text: 'North Attleboro: pastor is running about 3 min long today' },
|
||||||
|
{ user: 'U002', text: 'Copy. Apollo Beach, hold your bumper until you hear from us' },
|
||||||
|
{ user: 'U003', text: 'Lyrics slide 3 of "Living Hope" has a typo — fixing for second service' },
|
||||||
|
{ user: 'U005', text: 'Stream bitrate dipped for a sec, back to normal now' },
|
||||||
|
{ user: 'U002', text: 'Offering moment is *after* the sermon this week, not before' },
|
||||||
|
{ user: 'U004', text: 'Anyone else seeing the ProPresenter stage display lag?' },
|
||||||
|
{ user: 'U003', text: 'Kids check-in is slammed, could use one more volunteer at the desk' },
|
||||||
|
{ user: 'U002', text: 'Great first service everyone :clap: reset in 15' },
|
||||||
|
];
|
||||||
|
let chatterIx = 0;
|
||||||
|
|
||||||
|
function tickNew() {
|
||||||
|
const line = CHATTER[chatterIx % CHATTER.length];
|
||||||
|
chatterIx += 1;
|
||||||
|
const m = push('C001', { user: line.user, text: line.text });
|
||||||
|
console.log(`[slack-mock] + new message ${m.ts} from ${line.user}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tickEdit() {
|
||||||
|
const list = history.get('C001');
|
||||||
|
const recent = list.slice(-8).filter((m) => m.user && !m.subtype && !m.edited);
|
||||||
|
if (!recent.length) return;
|
||||||
|
const m = recent[Math.floor(Math.random() * recent.length)];
|
||||||
|
m.text += ' — fixed';
|
||||||
|
m.edited = { user: m.user, ts: ts() };
|
||||||
|
console.log(`[slack-mock] ~ edited ${m.ts}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tickDelete() {
|
||||||
|
const list = history.get('C001');
|
||||||
|
const candidates = list.slice(-10, -2).filter((m) => m.user && !m.subtype && !m.reply_count);
|
||||||
|
if (!candidates.length) return;
|
||||||
|
const victim = candidates[Math.floor(Math.random() * candidates.length)];
|
||||||
|
list.splice(list.indexOf(victim), 1);
|
||||||
|
console.log(`[slack-mock] - deleted ${victim.ts}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tickReply() {
|
||||||
|
const list = history.get('C001');
|
||||||
|
const parent = list.find((m) => m.reply_count);
|
||||||
|
if (!parent) return;
|
||||||
|
const reply = { type: 'message', ts: ts(), user: ['U002', 'U003', 'U005'][parent.replies.length % 3], text: `Thread reply ${parent.replies.length + 1} :eyes:`, thread_ts: parent.ts };
|
||||||
|
parent.replies.push(reply);
|
||||||
|
parent.reply_count = parent.replies.length;
|
||||||
|
parent.latest_reply = reply.ts;
|
||||||
|
console.log(`[slack-mock] ↳ reply on ${parent.ts} (${parent.reply_count} total)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
setInterval(tickNew, 8000).unref();
|
||||||
|
setInterval(tickEdit, 25000).unref();
|
||||||
|
setInterval(tickDelete, 40000).unref();
|
||||||
|
setInterval(tickReply, 30000).unref();
|
||||||
|
|
||||||
|
/* ── plumbing ───────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function send(res, status, body, headers = {}) {
|
||||||
|
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', ...headers });
|
||||||
|
res.end(JSON.stringify(body));
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBody(req) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let data = '';
|
||||||
|
req.on('data', (c) => { data += c; });
|
||||||
|
req.on('end', () => resolve(data));
|
||||||
|
req.on('error', () => resolve(''));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseParams(req, url, raw) {
|
||||||
|
const params = Object.fromEntries(url.searchParams.entries());
|
||||||
|
const ctype = String(req.headers['content-type'] || '');
|
||||||
|
if (raw) {
|
||||||
|
if (ctype.includes('application/json')) {
|
||||||
|
try { Object.assign(params, JSON.parse(raw)); } catch { /* ignore */ }
|
||||||
|
} else {
|
||||||
|
for (const [k, v] of new URLSearchParams(raw)) params[k] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenOf(req, params) {
|
||||||
|
const m = /^Bearer\s+(\S+)/.exec(String(req.headers.authorization || ''));
|
||||||
|
return m ? m[1] : String(params.token || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function identity(token) {
|
||||||
|
if (token === 'xoxp-revoked') return { error: 'token_revoked' };
|
||||||
|
if (/^xoxp-/.test(token)) return { ok: true, url: 'https://waters.slack.com/', team: 'Waters Church', user: 'apollo.beach', team_id: 'T001', user_id: 'U001', noscope: token === 'xoxp-noscope' };
|
||||||
|
if (/^xoxb-/.test(token)) return { ok: true, url: 'https://waters.slack.com/', team: 'Waters Church', user: 'proddash', team_id: 'T001', user_id: 'UBOT', bot_id: 'B001' };
|
||||||
|
return { error: 'invalid_auth' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function channelRecord(c) {
|
||||||
|
return { id: c.id, name: c.name, name_normalized: c.name, is_channel: !c.is_private, is_group: c.is_private, is_private: c.is_private, is_member: c.is_member, is_archived: false, num_members: 12 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicMessage(m) {
|
||||||
|
const { replies, ...rest } = m;
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findChannel(ref) {
|
||||||
|
const s = String(ref || '');
|
||||||
|
return CHANNELS.find((c) => c.id === s || c.name === s.replace(/^#/, ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = http.createServer(async (req, res) => {
|
||||||
|
const url = new URL(req.url, `http://127.0.0.1:${PORT}`);
|
||||||
|
const raw = req.method === 'POST' ? await readBody(req) : '';
|
||||||
|
const params = parseParams(req, url, raw);
|
||||||
|
const method = url.pathname.replace(/^\/api\//, '');
|
||||||
|
const summary = Object.entries(params).filter(([k]) => k !== 'token').map(([k, v]) => `${k}=${String(v).slice(0, 40)}`).join(' ');
|
||||||
|
console.log(`[slack-mock] ${req.method} ${url.pathname} ${summary}`);
|
||||||
|
|
||||||
|
if (!url.pathname.startsWith('/api/')) return send(res, 404, { ok: false, error: 'unknown_method' });
|
||||||
|
|
||||||
|
const token = tokenOf(req, params);
|
||||||
|
const who = identity(token);
|
||||||
|
if (!who.ok) return send(res, 200, { ok: false, error: who.error });
|
||||||
|
|
||||||
|
switch (method) {
|
||||||
|
case 'auth.test': {
|
||||||
|
const { noscope, ...rest } = who;
|
||||||
|
return send(res, 200, rest);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'users.info': {
|
||||||
|
const u = USERS[String(params.user)];
|
||||||
|
if (!u) return send(res, 200, { ok: false, error: 'user_not_found' });
|
||||||
|
return send(res, 200, { ok: true, user: { id: params.user, name: u.name, real_name: u.real_name, is_bot: Boolean(u.is_bot), profile: { display_name: u.display_name, real_name: u.real_name } } });
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'conversations.list': {
|
||||||
|
const types = String(params.types || 'public_channel').split(',');
|
||||||
|
let list = CHANNELS.filter((c) => (c.is_private ? types.includes('private_channel') : types.includes('public_channel')));
|
||||||
|
if (who.bot_id) list = list.filter((c) => c.is_member); // a bot sees only what it's in
|
||||||
|
const limit = Math.max(1, Math.min(1000, Number(params.limit) || 100));
|
||||||
|
const start = params.cursor ? Number(Buffer.from(String(params.cursor), 'base64').toString()) || 0 : 0;
|
||||||
|
const page = list.slice(start, start + limit);
|
||||||
|
const next = start + limit < list.length ? Buffer.from(String(start + limit)).toString('base64') : '';
|
||||||
|
return send(res, 200, { ok: true, channels: page.map(channelRecord), response_metadata: { next_cursor: next } });
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'conversations.info': {
|
||||||
|
const c = findChannel(params.channel);
|
||||||
|
if (!c) return send(res, 200, { ok: false, error: 'channel_not_found' });
|
||||||
|
return send(res, 200, { ok: true, channel: channelRecord(c) });
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'conversations.history': {
|
||||||
|
const c = findChannel(params.channel);
|
||||||
|
if (!c) return send(res, 200, { ok: false, error: 'channel_not_found' });
|
||||||
|
if (who.noscope) return send(res, 200, { ok: false, error: 'missing_scope', needed: 'channels:history', provided: 'channels:read,chat:write' });
|
||||||
|
if (!c.is_member) return send(res, 200, { ok: false, error: 'not_in_channel' });
|
||||||
|
const limit = Math.max(1, Math.min(1000, Number(params.limit) || 100));
|
||||||
|
const list = history.get(c.id);
|
||||||
|
const window = list.slice(-limit).reverse().map(publicMessage);
|
||||||
|
return send(res, 200, { ok: true, messages: window, has_more: list.length > limit, pin_count: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'conversations.replies': {
|
||||||
|
const c = findChannel(params.channel);
|
||||||
|
if (!c) return send(res, 200, { ok: false, error: 'channel_not_found' });
|
||||||
|
if (!c.is_member) return send(res, 200, { ok: false, error: 'not_in_channel' });
|
||||||
|
const parent = history.get(c.id).find((m) => m.ts === String(params.ts));
|
||||||
|
if (!parent) return send(res, 200, { ok: false, error: 'thread_not_found' });
|
||||||
|
const limit = Math.max(1, Math.min(1000, Number(params.limit) || 100));
|
||||||
|
return send(res, 200, { ok: true, messages: [publicMessage(parent), ...(parent.replies || []).slice(0, limit - 1)], has_more: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'chat.postMessage': {
|
||||||
|
const c = findChannel(params.channel);
|
||||||
|
if (!c) return send(res, 200, { ok: false, error: 'channel_not_found' });
|
||||||
|
if (!c.is_member) return send(res, 200, { ok: false, error: 'not_in_channel' });
|
||||||
|
const text = String(params.text || '');
|
||||||
|
if (!text.trim()) return send(res, 200, { ok: false, error: 'no_text' });
|
||||||
|
if (text.includes('!429')) return send(res, 429, { ok: false, error: 'ratelimited' }, { 'Retry-After': '3' });
|
||||||
|
let m;
|
||||||
|
if (who.bot_id && params.username) {
|
||||||
|
m = push(c.id, { subtype: 'bot_message', bot_id: who.bot_id, username: String(params.username), icons: params.icon_emoji ? { emoji: String(params.icon_emoji) } : undefined, text });
|
||||||
|
} else if (who.bot_id) {
|
||||||
|
m = push(c.id, { user: who.user_id, bot_id: who.bot_id, bot_profile: { id: who.bot_id, name: 'ProdDash' }, text });
|
||||||
|
} else {
|
||||||
|
m = push(c.id, { user: who.user_id, text });
|
||||||
|
}
|
||||||
|
if (params.thread_ts) m.thread_ts = String(params.thread_ts);
|
||||||
|
console.log(`[slack-mock] ✉ posted to #${c.name} as ${params.username || who.user}: ${text.slice(0, 60)}`);
|
||||||
|
return send(res, 200, { ok: true, channel: c.id, ts: m.ts, message: publicMessage(m) });
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return send(res, 200, { ok: false, error: 'unknown_method' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(PORT, '127.0.0.1', () => {
|
||||||
|
console.log(`[slack-mock] Slack Web API mock on http://127.0.0.1:${PORT}/api`);
|
||||||
|
console.log('[slack-mock] tokens: xoxp-anything (user "Apollo Beach"), xoxb-anything (bot), xoxp-revoked, xoxp-noscope');
|
||||||
|
console.log('[slack-mock] run ProdDash with: SLACK_API_BASE=http://127.0.0.1:%d/api node server.js', PORT);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user