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