Send Message 1.0.0: one PIN, one place to send from; Slack 1.3.0 and ProdCom 1.4.0 become messaging providers
A new send-message module owns everything a dashboard sends: the PIN
(switchable off in admin), the unlock tokens, the Send message and Quick
replies tiles, the quick replies and quick emojis. It opens no upstream
connection of its own — it finds every module declaring
provides: ["messaging"] through the shell's module list and hands each
message to that module's POST /messaging/send over loopback, so Slack keeps
its one Slack connection and ProdCom its one ProdCom connection. Providers
check the browser's unlock token with GET /verify over loopback before
touching their upstream, so no browser on the network can post as the
location without having entered the PIN here.
Slack 1.3.0 is view-only plus reactions: the Sending group, PIN, send tiles
and quick replies are gone; it serves the messaging descriptor (channels as
recipients) and its reactions ride on the Send Message unlock. A layout's
old Send message / Quick replies tiles draw nothing until removed.
ProdCom 1.4.0 serves the descriptor as identities — only channels with
typingEnabled, since typed text goes out *as* a channel — and posts with
POST /api/v1/channels/{id}/send. Its tile is a click-to-target like Slack's
(accent stripe along the bottom), and typed entries carry a keyboard mark.
The tiles: click any Slack or ProdCom transcript to point sends there; a
Send as menu (only for platforms with identities, hidden otherwise) picks
the ProdCom channel to speak as; the choice is saved with the tile so named
layouts remember it. Wording follows the role — "Send message to #ops…" /
"Send message to ProdCom as FOH…". The PIN pad's dots ride on a real
numeric input so a phone's keyboard appears. Outcome is an icon beside
Send (check / blocked, five seconds, sentence as tooltip). A pill on the
box's bottom edge trades box height for rows of quick-emoji keys, a row
at a time; the single row scrolls sideways on a narrow tile with Send
pinned in place.
The module guide documents the provider contract and the page bus
(window.proddashMessaging + proddash:messaging:* events) the tiles share;
the ProdCom mock gained the send route.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
3b81efcb51
commit
1274360ee6
+114
-782
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,12 @@
|
||||
{
|
||||
"id": "slack",
|
||||
"name": "Slack",
|
||||
"version": "1.2.1",
|
||||
"version": "1.3.0",
|
||||
"proddash": ">=1.4.0",
|
||||
"description": "Live transcripts of this location's Slack channels, plus PIN-protected sending as the location",
|
||||
"description": "Live transcripts of this location's Slack channels; messages and reactions leave as the location through the Send Message module",
|
||||
"provides": [
|
||||
"messaging"
|
||||
],
|
||||
"client": "client.js",
|
||||
"server": "server.js",
|
||||
"style": "style.css",
|
||||
@@ -18,14 +21,10 @@
|
||||
"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, users:read, reactions:write (emoji reactions) and emoji:read (the workspace's custom emoji). 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."
|
||||
"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, users:read, reactions:write (emoji reactions) and emoji:read (the workspace's custom emoji). 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 sent from this machine through the Send Message module are then literally from the location. A bot token (xoxb-…) works too; it posts as the app, with \"Send as\" naming the location."
|
||||
},
|
||||
"Channels": {
|
||||
"help": "Every channel picked here becomes its own transcript tile in the + picker, titled with the channel's name. The Send message and Quick replies tiles post to whichever transcript was clicked last — its bottom edge lights up in the accent colour — and until one is clicked, to the first channel here whose transcript is on that dashboard: with a single transcript up, that one. A send never goes to a channel the dashboard is not showing. Only channels the account has joined are listed; join one in Slack first. Each channel is read on its own schedule, all of them together kept inside Slack's rate limit."
|
||||
},
|
||||
"Sending": {
|
||||
"toggle": "sendEnabled",
|
||||
"help": "Messages and emoji reactions leave this machine only after the PIN is entered — on a Send or Quick replies tile, or in the reaction menu a right-click on a transcript message opens. One PIN unlocks every Slack tile on that dashboard page until Auto-lock minutes pass without a send; a reload asks again. Sending stays refused until a 4–8 digit PIN is set here. A message goes to the channel of the transcript clicked last. Quick emojis are sent with one press from the Send message tile and offered first when reacting. Quick replies are one per line, Label | Message, with an optional | #channel-name to bind that one to a channel: a bound reply always goes there, whichever transcript is selected."
|
||||
"help": "Every channel picked here becomes its own transcript tile in the + picker, titled with the channel's name, and a destination the Send Message module's tiles can post to: a click on a transcript points them at its channel (its bottom edge lights up in the accent colour) — that is the only way a Slack channel is chosen; the Send as menu is for platforms that send as an identity. Only channels the account has joined are listed; join one in Slack first. Each channel is read on its own schedule, all of them together kept inside Slack's rate limit."
|
||||
}
|
||||
},
|
||||
"configSchema": {
|
||||
@@ -41,7 +40,7 @@
|
||||
"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."
|
||||
"help": "Only used with a bot token — a user token always sends as its own account; needs the chat:write.customize scope. Applies to messages the Send Message module posts through this account."
|
||||
},
|
||||
"channels": {
|
||||
"type": "multiselect",
|
||||
@@ -49,7 +48,7 @@
|
||||
"default": [],
|
||||
"group": "Channels",
|
||||
"optionsRoute": "/channels",
|
||||
"help": "One transcript tile per channel; until a transcript is clicked, sends go to the first channel here that the dashboard is actually showing. Only channels the account has joined are listed — join one in Slack first."
|
||||
"help": "One transcript tile per channel, and one destination each for the Send Message module. Only channels the account has joined are listed — join one in Slack first."
|
||||
},
|
||||
"pollSeconds": {
|
||||
"type": "number",
|
||||
@@ -69,39 +68,6 @@
|
||||
"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; the pad unlocks by itself once they are all in."
|
||||
},
|
||||
"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 bind one to a channel — it then ignores which transcript is selected."
|
||||
},
|
||||
"quickEmojis": {
|
||||
"type": "string",
|
||||
"label": "Quick emojis",
|
||||
"default": "👍 🙏 ✅ 👀 🎉",
|
||||
"group": "Sending",
|
||||
"help": "Emoji or :codes: separated by spaces, eight at most. One press sends one from the Send message tile; they also head the reaction menu."
|
||||
}
|
||||
},
|
||||
"instanceSchema": {
|
||||
|
||||
+108
-339
@@ -6,26 +6,29 @@
|
||||
* One Slack Web API connection per ProdDash machine — the campus's shared
|
||||
* location account (a user token), or a bot — polling the configured
|
||||
* channels' recent history (one poller per channel, all inside one rate
|
||||
* budget) and fanning the lot out to every tile over SSE, 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.
|
||||
* budget) and fanning the lot out to every tile over SSE. Browsers never
|
||||
* talk to Slack; the token stays here.
|
||||
*
|
||||
* GET /state snapshot: identity, channels (keyed by id), sending state
|
||||
* GET /stream SSE `state` events (change-detected, heartbeated)
|
||||
* GET /channels admin multiselect options — the channels the account is in
|
||||
* POST /unlock { pin } → { token, expiresAt } (constant-time, lockout)
|
||||
* POST /lock Bearer → forget that unlock token
|
||||
* GET /quick Bearer → preset labels + indexes (never the message text)
|
||||
* POST /send Bearer, { text, channel } → chat.postMessage
|
||||
* POST /react Bearer, { channel, ts, name, remove? } → reactions.add / .remove
|
||||
* GET /emoji { standard: {code: emoji}, custom: {name: {url}|{char}}, version }
|
||||
* Sending is not this module's: the Send Message module owns the PIN, the
|
||||
* send tiles and the quick replies, and hands a message to this module's
|
||||
* POST /messaging/send over loopback, so it still leaves through this one
|
||||
* connection, as the location. Every outbound action — a message, an emoji
|
||||
* reaction from a transcript — carries that module's unlock token and is
|
||||
* checked with it (GET /verify, over loopback) before Slack is asked. This
|
||||
* module has no PIN of its own and cannot post without one entered there.
|
||||
*
|
||||
* GET /state snapshot: identity, channels (keyed by id)
|
||||
* GET /stream SSE `state` events (change-detected, heartbeated)
|
||||
* GET /channels admin multiselect options — the channels the account is in
|
||||
* GET /messaging the configured channels as destinations (the module guide's contract)
|
||||
* POST /messaging/send Bearer (Send Message unlock), { destination, text } → chat.postMessage
|
||||
* POST /react Bearer (Send Message unlock), { channel, ts, name, remove? } → reactions.add / .remove
|
||||
* GET /emoji { standard: {code: emoji}, custom: {name: {url}|{char}}, version }
|
||||
* GET /emoji-image/<name>, GET /avatar/<u/USER|b/BOT> images proxied from Slack's CDN
|
||||
* POST /quick Bearer, { index, channel } → post that preset
|
||||
*
|
||||
* `channel` on /send and /quick must be one of the configured channel ids
|
||||
* (400 otherwise): a PIN-unlocked browser chooses among the admin's channels,
|
||||
* never an arbitrary one. A quick reply with its own "| #channel" ignores the
|
||||
* request's channel — an explicit binding wins over the selection.
|
||||
* `destination` / `channel` must be one of the configured channel ids (400
|
||||
* otherwise): an unlocked browser chooses among the admin's channels, never
|
||||
* an arbitrary one.
|
||||
*
|
||||
* Rate budget. Slack allows an internal app roughly 50+ conversations.history
|
||||
* calls a minute per token (Tier 3). The module keeps all its pollers under
|
||||
@@ -46,8 +49,6 @@
|
||||
* 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(/\/+$/, '');
|
||||
|
||||
@@ -92,14 +93,11 @@ const FIRST_READ_STAGGER_MS = 250;
|
||||
const MIN_SLOT_GAP_MS = 200;
|
||||
/** tiles() waits at most this long for the first conversations.info answers after boot. */
|
||||
const NAMES_WAIT_MS = 1500;
|
||||
const PIN_RE = /^\d{4,8}$/;
|
||||
const PIN_MAX_WRONG = 5;
|
||||
const PIN_LOCKOUT_MS = 30000;
|
||||
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;
|
||||
/** The module that owns the PIN and the unlock tokens — see verifyUnlock(). */
|
||||
const SENDER_ID = 'send-message';
|
||||
|
||||
/** Set by init(), read by routes() and tiles() — all rebuilt together on remount. */
|
||||
let current = null;
|
||||
@@ -238,7 +236,6 @@ const EMOJI_NAMES = require('./emoji.json').names;
|
||||
const EMOJI_CODE_RE = /^([a-z0-9_+-]{1,100})(?:::(skin-tone-[2-6]))?$/i;
|
||||
/** How long the workspace's custom emoji list is believed before emoji.list is asked again. */
|
||||
const EMOJI_LIST_TTL_MS = 60 * 60 * 1000;
|
||||
let emojiReverse = null; // character (variation selector stripped) → the name Slack knows it by
|
||||
|
||||
/** The character for a standard code, '' for a custom or unknown one. */
|
||||
function emojiChar(code) {
|
||||
@@ -250,45 +247,6 @@ function emojiChar(code) {
|
||||
return tone ? base.replace(/\uFE0F/g, '') + tone : base;
|
||||
}
|
||||
|
||||
/** The short name for a character ('' if Slack has none) — a reaction needs the name, not the glyph. */
|
||||
function emojiName(char) {
|
||||
if (!emojiReverse) {
|
||||
emojiReverse = new Map();
|
||||
for (const [name, ch] of Object.entries(EMOJI_NAMES)) {
|
||||
if (name.startsWith('skin-tone-')) continue;
|
||||
const key = ch.replace(/\uFE0F/g, '');
|
||||
if (!emojiReverse.has(key)) emojiReverse.set(key, name); // keys are sorted: "+1" wins over "thumbsup"
|
||||
}
|
||||
}
|
||||
const s = String(char || '').replace(/\uFE0F/g, '');
|
||||
const tone = /[\u{1F3FB}-\u{1F3FF}]$/u.exec(s);
|
||||
if (tone) {
|
||||
const base = emojiReverse.get(s.slice(0, -tone[0].length));
|
||||
const toneName = Object.keys(EMOJI_NAMES).find((n) => n.startsWith('skin-tone-') && EMOJI_NAMES[n] === tone[0]);
|
||||
return base && toneName ? `${base}::${toneName}` : '';
|
||||
}
|
||||
return emojiReverse.get(s) || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* The admin's "Quick emojis" — "👍 🙏 :white_check_mark: :party_parrot:" →
|
||||
* [{ name, char }]. A standard emoji gets both; a custom code keeps its name
|
||||
* and no character (the browser draws the image); a glyph Slack has no name
|
||||
* for can be sent but not reacted with (name ''). Eight at most.
|
||||
*/
|
||||
function parseQuickEmojis(text) {
|
||||
const out = [];
|
||||
for (const tok of String(text || '').split(/\s+/).filter(Boolean)) {
|
||||
const code = /^:([^:\s]+(?:::skin-tone-[2-6])?):$/i.exec(tok);
|
||||
if (code) {
|
||||
const name = code[1].toLowerCase();
|
||||
if (EMOJI_CODE_RE.test(name)) out.push({ name, char: emojiChar(name) });
|
||||
} else out.push({ name: emojiName(tok), char: tok });
|
||||
if (out.length >= 8) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/* Avatars and custom emoji are images on Slack's CDN; browsers only talk to
|
||||
ProdDash, so the module fetches them and keeps them a while. */
|
||||
const IMAGE_CACHE_MAX = 400;
|
||||
@@ -453,38 +411,30 @@ function bearer(req) {
|
||||
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 —
|
||||
* a reply that carries one always goes there, whatever transcript is selected.
|
||||
* Is the unlock token on this request good? Only the Send Message module
|
||||
* knows — it owns the PIN — so ask it over loopback. Answers { ok: true,
|
||||
* expiresAt } or { ok: false, status, error, code } ready to send back.
|
||||
*/
|
||||
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 });
|
||||
async function verifyUnlock(req, shellPort) {
|
||||
const token = bearer(req);
|
||||
if (!token) return { ok: false, status: 401, error: 'Locked — enter the PIN', code: 'locked' };
|
||||
if (!shellPort) return { ok: false, status: 503, error: 'This ProdDash cannot verify a PIN (needs 1.4.0 or newer)', code: 'no_shell_port' };
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(`http://127.0.0.1:${shellPort}/api/modules/${SENDER_ID}/verify`, {
|
||||
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
} catch (err) {
|
||||
return { ok: false, status: 502, error: `Cannot reach the Send Message module (${err.cause?.code || err.message})`, code: 'sender_unreachable' };
|
||||
}
|
||||
return out;
|
||||
if (res.status === 404) return { ok: false, status: 503, error: 'Install the Send Message module to send or react from a dashboard', code: 'no_sender' };
|
||||
let body = {};
|
||||
try { body = await res.json(); } catch { /* a non-JSON refusal */ }
|
||||
if (res.status === 401 || body?.ok === false) return { ok: false, status: 401, error: body?.error || 'Locked — enter the PIN', code: body?.code || 'locked' };
|
||||
if (!res.ok) return { ok: false, status: 502, error: `Send Message module answered HTTP ${res.status}`, code: 'sender_error' };
|
||||
return { ok: true, expiresAt: Number(body?.expiresAt) || 0 };
|
||||
}
|
||||
|
||||
const SYSTEM_SUBTYPES = new Set([
|
||||
@@ -497,8 +447,9 @@ const SYSTEM_SUBTYPES = new Set([
|
||||
/* ── the module ─────────────────────────────────────────────────────── */
|
||||
|
||||
module.exports = {
|
||||
init({ config, log }) {
|
||||
init({ config, log, shell }) {
|
||||
/* ── config ── */
|
||||
const shellPort = Number(shell && shell.port) || 0;
|
||||
const token = String(config.token || '').trim();
|
||||
const sendAs = String(config.sendAs || '').trim();
|
||||
const channelIds = configuredChannels(config);
|
||||
@@ -508,12 +459,6 @@ module.exports = {
|
||||
const historyCount = Math.min(HISTORY_MAX, Math.max(HISTORY_MIN, Math.trunc(Number(config.historyCount)) || 40));
|
||||
const includeReplies = Boolean(config.includeReplies);
|
||||
const threadFetchesPerPoll = Math.max(1, Math.floor(THREAD_FETCHES_PER_POLL / Math.max(1, channelIds.length)));
|
||||
const sendEnabled = Boolean(config.sendEnabled);
|
||||
const pin = String(config.pin || '').trim();
|
||||
const pinOk = PIN_RE.test(pin);
|
||||
const idleMs = Math.min(240, Math.max(1, Number(config.pinIdleMinutes) || 10)) * 60 * 1000;
|
||||
const quickReplies = parseQuickReplies(config.quickReplies);
|
||||
const quickEmojis = parseQuickEmojis(config.quickEmojis);
|
||||
|
||||
/* ── state (all inside this closure so a re-init starts clean) ── */
|
||||
let stopped = false;
|
||||
@@ -531,8 +476,6 @@ module.exports = {
|
||||
let lastInterest = Date.now();
|
||||
const epoch = Date.now(); // the phase-locked slots count from here
|
||||
const streams = new Set();
|
||||
const unlocks = new Map(); // token -> { expiresAt, lastSendAt, ip }
|
||||
const pinAttempts = new Map(); // ip -> { count, lockedUntil }
|
||||
|
||||
/**
|
||||
* One channel's poller: its own window, thread cache, timer and error,
|
||||
@@ -724,21 +667,6 @@ module.exports = {
|
||||
}
|
||||
}
|
||||
|
||||
/** "#name" or a channel id → the channel id, or '' when the account can't see it. */
|
||||
async function resolveChannelRef(ref) {
|
||||
const s = String(ref || '').trim();
|
||||
if (!s) return '';
|
||||
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);
|
||||
@@ -845,7 +773,7 @@ module.exports = {
|
||||
return { state: 'ok', message: `${labelOf(ch)} · live` };
|
||||
}
|
||||
|
||||
/** The admin health line: who, which channels, what's wrong, sending. */
|
||||
/** The admin health line: who, which channels, what's wrong. */
|
||||
function health() {
|
||||
if (!token) return { status: 'error', message: 'No Slack token — paste the location account’s User OAuth Token in Admin → Slack' };
|
||||
if (authError) return { status: 'error', message: authError };
|
||||
@@ -854,9 +782,8 @@ module.exports = {
|
||||
const list = [...pollers.values()];
|
||||
if (!list.length) return { status: 'error', message: `${who} · pick channels in Admin → Slack → Channels` };
|
||||
const names = list.map(labelOf).join(', ');
|
||||
const sending = !sendEnabled ? 'sending off' : !pinOk ? 'sending on — set a 4–8 digit PIN' : 'sending on';
|
||||
const problems = [...new Set(list.filter((ch) => ch.error).map((ch) => (ch.error.includes(labelOf(ch)) ? ch.error : `${labelOf(ch)}: ${ch.error}`)))];
|
||||
if (problems.length) return { status: 'error', message: `${who} · ${names} · ${problems.join(' · ')} · ${sending}` };
|
||||
if (problems.length) return { status: 'error', message: `${who} · ${names} · ${problems.join(' · ')}` };
|
||||
if (list.some((ch) => !ch.lastPollAt)) return { status: 'connecting', message: `${who} · ${names} · first read…` };
|
||||
const stale = list.filter(isStale);
|
||||
if (stale.length) {
|
||||
@@ -864,7 +791,7 @@ module.exports = {
|
||||
return { status: 'connecting', message: `${who} · ${names} · ${ago}` };
|
||||
}
|
||||
const cadence = intervalMs > pollMs ? ` · every ${intervalMs / 1000} s (rate budget)` : '';
|
||||
return { status: 'ok', message: `${who} · ${names}${cadence} · ${sending}` };
|
||||
return { status: 'ok', message: `${who} · ${names}${cadence}` };
|
||||
}
|
||||
|
||||
function channelView(ch) {
|
||||
@@ -899,10 +826,6 @@ module.exports = {
|
||||
includeReplies,
|
||||
historyCount,
|
||||
pollSeconds: intervalMs / 1000,
|
||||
sending: { enabled: sendEnabled, pinSet: pinOk, idleMinutes: idleMs / 60000 },
|
||||
// the pad submits by itself once this many digits are in (never the PIN)
|
||||
pinLength: pinOk ? pin.length : 0,
|
||||
quickEmojis,
|
||||
emojiVersion,
|
||||
status: { state: h.status, message: h.message, lastPollAt },
|
||||
apiBase: API_BASE,
|
||||
@@ -1135,57 +1058,17 @@ module.exports = {
|
||||
]);
|
||||
}
|
||||
|
||||
/* ── 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* The channel a browser asked to post to. Only a configured channel id is
|
||||
* accepted — the selection is a choice among the admin's channels, never
|
||||
* a way to reach some other one. Nothing asked for → the first configured.
|
||||
* The channel a browser (or the Send Message module) asked to post to.
|
||||
* Only a configured channel id is accepted — the selection is a choice
|
||||
* among the admin's channels, never a way to reach some other one.
|
||||
*/
|
||||
function chooseTarget(requested) {
|
||||
const s = String(requested ?? '').trim();
|
||||
if (s) {
|
||||
if (pollers.has(s)) return { id: s };
|
||||
const name = lookupChannel(s);
|
||||
return { error: `${name ? `#${name}` : s} isn't one of the configured channels — pick it in Admin → Slack → Channels` };
|
||||
}
|
||||
if (!channelIds.length) return { error: 'No channel configured — pick channels in Admin → Slack → Channels' };
|
||||
return { id: channelIds[0] };
|
||||
if (!s) return { error: 'Which channel?' };
|
||||
if (pollers.has(s)) return { id: s };
|
||||
const name = lookupChannel(s);
|
||||
return { error: `${name ? `#${name}` : s} isn't one of the configured channels — pick it in Admin → Slack → Channels` };
|
||||
}
|
||||
|
||||
async function postMessage(targetChannelId, text) {
|
||||
@@ -1197,17 +1080,30 @@ module.exports = {
|
||||
return slackCall(token, 'chat.postMessage', params, { json: true });
|
||||
}
|
||||
|
||||
/** What the Send Message module may send to: the configured channels, as destinations. */
|
||||
function messagingDescriptor() {
|
||||
const h = health();
|
||||
return {
|
||||
label: 'Slack',
|
||||
identity: sendingAs(),
|
||||
destinationRole: 'recipient', // a channel is where a message goes, not who sends it
|
||||
status: { state: h.status, message: h.message },
|
||||
destinations: channelIds.map((id) => ({ id, label: `#${lookupChannel(id) || id}` })),
|
||||
maxChars: MAX_TEXT_CHARS,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or remove a reaction on behalf of an unlock — the same gate as a
|
||||
* send, since it is the location's name on it. The message's channel must
|
||||
* be one of the configured ones. `already_reacted` / `no_reaction` count
|
||||
* as done: Slack already holds the state that was asked for.
|
||||
* Add or remove a reaction on behalf of a verified unlock — the same
|
||||
* gate as a send, since it is the location's name on it. The message's
|
||||
* channel must be one of the configured ones. `already_reacted` /
|
||||
* `no_reaction` count as done: Slack already holds the state asked for.
|
||||
*/
|
||||
async function react(req, res, unlock, { channel, ts, name, remove }) {
|
||||
async function react(req, res, verified, { channel, ts, name, remove }) {
|
||||
if (!identity || authError) {
|
||||
return sendJson(res, 502, { error: authError || 'Slack token not checked yet — try again in a moment' });
|
||||
}
|
||||
const target = String(channel ?? '').trim() ? chooseTarget(channel) : { error: 'Which channel?' };
|
||||
const target = chooseTarget(channel);
|
||||
if (target.error) return sendJson(res, 400, { error: target.error, code: 'bad_channel' });
|
||||
const stamp = String(ts ?? '').trim();
|
||||
if (!/^\d{1,16}\.\d{1,9}$/.test(stamp)) return sendJson(res, 400, { error: 'Which message?' });
|
||||
@@ -1225,52 +1121,30 @@ module.exports = {
|
||||
return sendJson(res, status, { error: message, code: err.code });
|
||||
}
|
||||
}
|
||||
const u = unlocks.get(unlock.token);
|
||||
if (u) u.expiresAt = Date.now() + idleMs;
|
||||
log(`${remove ? 'removed' : 'added'} :${code}: on ${stamp} in #${targetName} as ${sendingAs()}`);
|
||||
if (pollers.has(target.id)) pollSoon(pollers.get(target.id));
|
||||
return sendJson(res, 200, { ok: true, expiresAt: u ? u.expiresAt : unlock.expiresAt, channel: `#${targetName}` });
|
||||
return sendJson(res, 200, { ok: true, expiresAt: verified.expiresAt, channel: `#${targetName}` });
|
||||
}
|
||||
|
||||
/**
|
||||
* Post on behalf of an unlock; answers the response. `targetId` is an
|
||||
* already-validated configured channel; `presetChannel` (a quick reply's
|
||||
* own "| #channel") overrides it.
|
||||
*/
|
||||
async function deliver(req, res, unlock, { text, targetId, presetChannel = '', what }) {
|
||||
const now = Date.now();
|
||||
if (now - unlock.lastSendAt < SEND_MIN_INTERVAL_MS) {
|
||||
return sendJson(res, 429, { error: 'One message per second — try again', code: 'too_fast' });
|
||||
}
|
||||
unlocks.get(unlock.token).lastSendAt = now;
|
||||
/** Post to an already-validated configured channel; answers the response. */
|
||||
async function deliver(req, res, verified, { text, targetId, what }) {
|
||||
if (!identity || authError) {
|
||||
return sendJson(res, 502, { error: authError || 'Slack token not checked yet — try again in a moment' });
|
||||
}
|
||||
let target = targetId || '';
|
||||
if (presetChannel) {
|
||||
try {
|
||||
target = await resolveChannelRef(presetChannel);
|
||||
} catch (err) {
|
||||
return sendJson(res, 502, { error: describeError(err, { name: identity.name, channel: presetChannel }) });
|
||||
}
|
||||
if (!target) return sendJson(res, 400, { error: `No channel named #${String(presetChannel).replace(/^#/, '')} that ${identity.name} can see` });
|
||||
}
|
||||
if (!target) return sendJson(res, 400, { error: 'No channel configured — pick channels in Admin → Slack → Channels' });
|
||||
const targetName = lookupChannel(target) || target;
|
||||
const targetName = lookupChannel(targetId) || targetId;
|
||||
try {
|
||||
const data = await postMessage(target, text);
|
||||
const u = unlocks.get(unlock.token);
|
||||
if (u) u.expiresAt = Date.now() + idleMs;
|
||||
const data = await postMessage(targetId, text);
|
||||
const preview = text.replace(/\s+/g, ' ').slice(0, 60);
|
||||
log(`${what} → #${targetName} as ${sendingAs()}: "${preview}${text.length > 60 ? '…' : ''}"`);
|
||||
if (pollers.has(target)) pollSoon(pollers.get(target));
|
||||
if (pollers.has(targetId)) pollSoon(pollers.get(targetId));
|
||||
return sendJson(res, 200, {
|
||||
ok: true,
|
||||
ts: String(data.ts || ''),
|
||||
label: `#${targetName}`,
|
||||
channel: `#${targetName}`,
|
||||
channelId: target,
|
||||
channelId: targetId,
|
||||
sendingAs: sendingAs(),
|
||||
expiresAt: u ? u.expiresAt : unlock.expiresAt,
|
||||
expiresAt: verified.expiresAt,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = describeError(err, { name: sendingAs() || identity.name, channel: targetName });
|
||||
@@ -1290,12 +1164,9 @@ module.exports = {
|
||||
}
|
||||
}, 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');
|
||||
if (!channelIds.length) log('no channels configured — pick some in /admin → Slack → Channels');
|
||||
else {
|
||||
if (!Array.isArray(config.channels) || !config.channels.length) {
|
||||
@@ -1311,8 +1182,8 @@ module.exports = {
|
||||
streams,
|
||||
snapshot,
|
||||
health,
|
||||
quickReplies,
|
||||
sendGate,
|
||||
shellPort,
|
||||
messagingDescriptor,
|
||||
deliver,
|
||||
react,
|
||||
/** Everything a tile needs to draw emoji: the shipped table plus the workspace's own. */
|
||||
@@ -1331,7 +1202,6 @@ module.exports = {
|
||||
if (kind === 'b') return botIcons.get(id) || '';
|
||||
return '';
|
||||
},
|
||||
unlockFor,
|
||||
chooseTarget,
|
||||
namesReady,
|
||||
touch() { lastInterest = Date.now(); },
|
||||
@@ -1347,44 +1217,8 @@ module.exports = {
|
||||
loadChannelList,
|
||||
channelIds,
|
||||
channelName: (id) => lookupChannel(id),
|
||||
pinOk,
|
||||
sendEnabled,
|
||||
identityName: () => identity?.name || '',
|
||||
sendingAs,
|
||||
unlock(req, res, given) {
|
||||
if (!sendEnabled) return sendJson(res, 403, { error: 'Sending is off — turn it on in Admin → Slack → Sending', code: 'sending_off' });
|
||||
if (!pinOk) return sendJson(res, 403, { error: 'Set a PIN in Admin → Slack → Sending', code: 'no_pin' });
|
||||
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() });
|
||||
},
|
||||
lock(req, res) {
|
||||
const t = bearer(req);
|
||||
if (t) unlocks.delete(t);
|
||||
return sendJson(res, 200, { ok: true });
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -1398,8 +1232,6 @@ module.exports = {
|
||||
}
|
||||
}
|
||||
clearInterval(heartbeat);
|
||||
clearInterval(sweeper);
|
||||
unlocks.clear(); // an admin Apply locks every send tile
|
||||
for (const res of streams) {
|
||||
try { res.end(); } catch { /* already gone */ }
|
||||
}
|
||||
@@ -1412,16 +1244,17 @@ module.exports = {
|
||||
|
||||
/**
|
||||
* The picker: one transcript per configured channel, titled with the
|
||||
* channel's name (the shell makes the entry's name the tile's title), then
|
||||
* the two send surfaces, one of each per dashboard. Between init() and a
|
||||
* poller's first answer a name may still be unknown — "#<id>" then.
|
||||
* channel's name (the shell makes the entry's name the tile's title).
|
||||
* Between init() and a poller's first answer a name may still be unknown
|
||||
* — "#<id>" then. The send surfaces that used to follow here are the Send
|
||||
* Message module's since 1.3.0; a layout's old Send message / Quick
|
||||
* replies tiles draw nothing until they are removed.
|
||||
*/
|
||||
async tiles({ config }) {
|
||||
const c = current;
|
||||
const ids = c ? c.channelIds : configuredChannels(config || {});
|
||||
if (c) await c.namesReady();
|
||||
const who = c?.sendingAs() ? ` as ${c.sendingAs()}` : '';
|
||||
const entries = ids.map((id) => ({
|
||||
return ids.map((id) => ({
|
||||
id: `transcript:${id}`,
|
||||
name: `#${(c && c.channelName(id)) || id}`,
|
||||
description: 'Live transcript',
|
||||
@@ -1429,27 +1262,6 @@ module.exports = {
|
||||
minSize: { w: 2, h: 2 },
|
||||
settings: { view: 'transcript', channel: id },
|
||||
}));
|
||||
entries.push(
|
||||
{
|
||||
id: 'send',
|
||||
name: 'Send message',
|
||||
description: `PIN-protected message to the transcript clicked last${who}`,
|
||||
single: true,
|
||||
defaultSize: { w: 3, h: 3 },
|
||||
minSize: { w: 2, h: 2 },
|
||||
settings: { view: 'send' },
|
||||
},
|
||||
{
|
||||
id: 'quick',
|
||||
name: 'Quick replies',
|
||||
description: `PIN-protected preset buttons${who}`,
|
||||
single: true,
|
||||
defaultSize: { w: 3, h: 2 },
|
||||
minSize: { w: 2, h: 1 },
|
||||
settings: { view: 'quick' },
|
||||
},
|
||||
);
|
||||
return entries;
|
||||
},
|
||||
|
||||
routes() {
|
||||
@@ -1498,22 +1310,6 @@ module.exports = {
|
||||
}
|
||||
}),
|
||||
|
||||
'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);
|
||||
},
|
||||
|
||||
// The emoji a tile may need to draw — fetched once per page, again when the version bumps.
|
||||
'GET /emoji': (req, res) => {
|
||||
if (!current) return unmounted(res);
|
||||
@@ -1530,35 +1326,19 @@ module.exports = {
|
||||
await serveImage(res, current.avatarUrl(decodeURIComponent(String(req.wildcard || '').replace(/^\/+/, ''))));
|
||||
}),
|
||||
|
||||
// A reaction as the location — PIN-gated like a send.
|
||||
'POST /react': guard(async (req, res) => {
|
||||
// The Send Message module reads this over loopback: where it may send.
|
||||
'GET /messaging': (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 });
|
||||
}
|
||||
await current.react(req, res, unlock, { channel: body.channel, ts: body.ts, name: body.name, remove: Boolean(body.remove) });
|
||||
}),
|
||||
|
||||
// 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,
|
||||
});
|
||||
sendJson(res, 200, current.messagingDescriptor());
|
||||
},
|
||||
|
||||
'POST /send': guard(async (req, res) => {
|
||||
// …and hands a message here, with the browser's unlock token. The token
|
||||
// is checked with the Send Message module first: no PIN entered there,
|
||||
// nothing is posted.
|
||||
'POST /messaging/send': guard(async (req, res) => {
|
||||
if (!current) return unmounted(res);
|
||||
const unlock = current.sendGate(req, res);
|
||||
if (!unlock) return;
|
||||
const v = await verifyUnlock(req, current.shellPort);
|
||||
if (!v.ok) return sendJson(res, v.status, { error: v.error, code: v.code });
|
||||
let body;
|
||||
try {
|
||||
body = await readJson(req);
|
||||
@@ -1568,34 +1348,23 @@ module.exports = {
|
||||
const text = String(body.text ?? '').replace(/\r\n/g, '\n').trim();
|
||||
if (!text) return sendJson(res, 400, { error: 'Nothing to send' });
|
||||
if (text.length > MAX_TEXT_CHARS) return sendJson(res, 400, { error: `Too long — ${MAX_TEXT_CHARS} characters at most` });
|
||||
const target = current.chooseTarget(body.channel);
|
||||
if (target.error) return sendJson(res, 400, { error: target.error, code: 'bad_channel' });
|
||||
await current.deliver(req, res, unlock, { text, targetId: target.id, what: 'sent' });
|
||||
const target = current.chooseTarget(body.destination ?? body.channel);
|
||||
if (target.error) return sendJson(res, 400, { error: target.error, code: 'bad_destination' });
|
||||
await current.deliver(req, res, v, { text, targetId: target.id, what: 'sent' });
|
||||
}),
|
||||
|
||||
'POST /quick': guard(async (req, res) => {
|
||||
// A reaction as the location — the same unlock as a send.
|
||||
'POST /react': guard(async (req, res) => {
|
||||
if (!current) return unmounted(res);
|
||||
const unlock = current.sendGate(req, res);
|
||||
if (!unlock) return;
|
||||
const v = await verifyUnlock(req, current.shellPort);
|
||||
if (!v.ok) return sendJson(res, v.status, { error: v.error, code: v.code });
|
||||
let body;
|
||||
try {
|
||||
body = await readJson(req);
|
||||
} catch (err) {
|
||||
return sendJson(res, 400, { error: err.message });
|
||||
}
|
||||
const 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' });
|
||||
// A channel the browser names is checked even when the preset's own
|
||||
// binding is about to override it — an unconfigured id is never accepted.
|
||||
const target = current.chooseTarget(body.channel);
|
||||
if (target.error && (body.channel || !preset.channel)) return sendJson(res, 400, { error: target.error, code: 'bad_channel' });
|
||||
await current.deliver(req, res, unlock, {
|
||||
text: preset.message,
|
||||
targetId: target.id || '',
|
||||
presetChannel: preset.channel,
|
||||
what: `quick reply "${preset.label}"`,
|
||||
});
|
||||
await current.react(req, res, v, { channel: body.channel, ts: body.ts, name: body.name, remove: Boolean(body.remove) });
|
||||
}),
|
||||
};
|
||||
},
|
||||
|
||||
+18
-308
@@ -1,7 +1,8 @@
|
||||
/* Slack module — scoped to its tiles, shell variables only.
|
||||
--sl-size (per tile, from the header's A− / A+) drives every font size.
|
||||
Transcript rows follow the ProdCom transcript's layout: a square picture,
|
||||
then a name line with the time, the text, the reactions. */
|
||||
then a name line with the time, the text, the reactions. The send tiles
|
||||
and the PIN pad moved to the Send Message module in 1.3.0. */
|
||||
|
||||
[data-module="slack"] .sl-wrap {
|
||||
--sl-size: 14px;
|
||||
@@ -34,8 +35,8 @@
|
||||
|
||||
/* ── the send target ── */
|
||||
|
||||
/* A click anywhere in a transcript's body makes its channel the one Send
|
||||
message and Quick replies post to. The chosen one carries a 3px accent
|
||||
/* A click anywhere in a transcript's body makes its channel the one the
|
||||
Send Message module's tiles post to. The chosen one carries a 3px accent
|
||||
stripe along the bottom edge of the tile body — drawn just inside our
|
||||
root, since the tile chrome around it belongs to the shell. The others
|
||||
hint at it faintly on hover. */
|
||||
@@ -279,307 +280,6 @@
|
||||
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: 4px;
|
||||
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: 4px;
|
||||
}
|
||||
[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-blank { visibility: hidden; }
|
||||
[data-module="slack"] .sl-pad.is-busy .sl-keys { opacity: 0.6; pointer-events: none; }
|
||||
|
||||
[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; }
|
||||
|
||||
/* A tile too short for the keypad (the smallest Send message / Quick replies
|
||||
tiles) shows the dots and a hint instead — digits typed on the keyboard
|
||||
still land, and a taller tile brings the keys back. .sl-wrap is the size
|
||||
container the height is measured against. */
|
||||
[data-module="slack"] .sl-pad-hint {
|
||||
display: none;
|
||||
flex: none;
|
||||
text-align: center;
|
||||
font-size: 0.78em;
|
||||
line-height: 1.3;
|
||||
color: var(--muted);
|
||||
}
|
||||
@container (max-height: 176px) {
|
||||
[data-module="slack"] .sl-pad .sl-keys { display: none; }
|
||||
[data-module="slack"] .sl-pad .sl-pad-hint { display: block; }
|
||||
[data-module="slack"] .sl-pad { justify-content: center; }
|
||||
}
|
||||
|
||||
/* ── 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; }
|
||||
|
||||
/* quick emojis: one press each sends it as a message of its own */
|
||||
[data-module="slack"] .sl-emoji-sendrow {
|
||||
flex: none;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
[data-module="slack"] .sl-emoji-send {
|
||||
flex: none;
|
||||
width: 2.1em;
|
||||
height: 2.1em;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--text) 5%, var(--panel));
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 1.1em;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
touch-action: manipulation;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
transition: background 0.1s, border-color 0.1s;
|
||||
}
|
||||
[data-module="slack"] .sl-emoji-send:hover { border-color: var(--accent); background: var(--border); }
|
||||
[data-module="slack"] .sl-emoji-send:active { background: color-mix(in srgb, var(--accent) 30%, var(--panel)); }
|
||||
[data-module="slack"] .sl-emoji-send.is-busy { opacity: 0.6; }
|
||||
[data-module="slack"] .sl-emoji-send.is-sent { background: var(--accent); border-color: transparent; }
|
||||
[data-module="slack"] .sl-emoji-send .sl-emoji { width: 1.2em; height: 1.2em; vertical-align: middle; }
|
||||
|
||||
/* 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; }
|
||||
|
||||
/* ── the reaction menu (on document.body, so nothing clips it) ── */
|
||||
|
||||
[data-module="slack"].sl-emoji-menu {
|
||||
@@ -671,7 +371,17 @@
|
||||
[data-module="slack"] .sl-emoji-msg:empty { display: none; }
|
||||
[data-module="slack"] .sl-emoji-msg.is-error { color: var(--danger); font-weight: 600; }
|
||||
|
||||
/* the PIN pad inside the menu: fixed sizes, no tile to scale with */
|
||||
[data-module="slack"] .sl-emoji-menu .sl-pad { max-width: 100%; gap: 6px; }
|
||||
[data-module="slack"] .sl-emoji-menu .sl-keys { grid-template-rows: repeat(4, 40px); }
|
||||
[data-module="slack"] .sl-emoji-menu .sl-key { font-size: 17px; }
|
||||
/* locked: the PIN is entered in the Send Message module's tile, not here */
|
||||
[data-module="slack"] .sl-emoji-locked {
|
||||
padding: 4px 2px 2px;
|
||||
font-size: 0.86em;
|
||||
line-height: 1.4;
|
||||
color: var(--muted);
|
||||
}
|
||||
[data-module="slack"] .sl-emoji-locked b { color: var(--text); font-weight: 700; }
|
||||
[data-module="slack"] .sl-emoji-reason {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: var(--warn);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user