From 1274360ee66f34f5b00151ee5039da739bfde665 Mon Sep 17 00:00:00 2001 From: Waters Church Date: Mon, 5 Oct 2026 13:55:55 -0400 Subject: [PATCH] Send Message 1.0.0: one PIN, one place to send from; Slack 1.3.0 and ProdCom 1.4.0 become messaging providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 69 +- docs/MODULE-GUIDE.md | 116 ++- modules/prodcom-transcript/client.js | 97 +- modules/prodcom-transcript/module.json | 11 +- modules/prodcom-transcript/server.js | 228 ++++- modules/prodcom-transcript/style.css | 31 + modules/send-message/client.js | 1189 ++++++++++++++++++++++++ modules/send-message/module.json | 63 ++ modules/send-message/server.js | 806 ++++++++++++++++ modules/send-message/style.css | 527 +++++++++++ modules/slack/client.js | 896 +++--------------- modules/slack/module.json | 52 +- modules/slack/server.js | 447 +++------ modules/slack/style.css | 326 +------ tools/prodcom-mock.js | 79 +- 15 files changed, 3409 insertions(+), 1528 deletions(-) create mode 100644 modules/send-message/client.js create mode 100644 modules/send-message/module.json create mode 100644 modules/send-message/server.js create mode 100644 modules/send-message/style.css diff --git a/README.md b/README.md index eda564d..d07fc91 100644 --- a/README.md +++ b/README.md @@ -318,7 +318,13 @@ below. Nothing is installed without someone clicking. - **ProdCom** — live transcript stream with in-place updates of in-progress speech, channel colors, per-tile channel visibility toggles and group presets, timestamps, text size, a top-down / bottom-up direction toggle - and jump-to-latest in the title bar. + and jump-to-latest in the title bar. Text typed into a channel (from a + dashboard, or ProdCom's own box) is marked with a keyboard; an automation's + with a bolt. The module also offers its channels to **Send Message** as + identities: a click anywhere in the transcript points that module's tiles + at ProdCom (the tile's bottom edge lights up in the accent color), and the + channel the text goes out *as* is picked from their Send as menu — only + channels ProdCom lets text be typed onto are offered. Admin config: ProdCom server (host : port), API key. Tile settings: channel icons, direction and timestamps. - **ProPresenter** — what's live and what's next, with slide progress, @@ -406,29 +412,47 @@ below. Nothing is installed without someone clicking. has signed up for are hidden until you ask for them — that list comes from the service type, since Planning Center only returns a team once somebody is on it. -- **Slack** — live transcripts of this location's Slack channels, plus - PIN-protected sending as the location. The + picker lists one transcript - tile per configured channel, titled `#channel`, and two send surfaces (one - of each per dashboard): **Send message**, a free-text box with the admin's - quick emojis one press away, and **Quick replies**, a grid of preset - buttons. Both post to the channel of whichever transcript was clicked last - — its bottom edge lights up in the accent color — and to the first - configured channel until one is clicked. Messages arrive with avatars, - names and times, Slack's inline formatting and the workspace's custom - emoji; a right-click on one opens the reaction menu, and text size and - jump-to-latest sit in the title bar. Nothing leaves the machine until the - PIN is entered: one PIN unlocks every Slack tile on that dashboard page - until the auto-lock idles out, a reload asks again, and the lock button on - a send tile's title bar ends it early. - Admin config is in four groups: **Slack account** (the location account's +- **Slack** — live transcripts of this location's Slack channels. The + + picker lists one transcript tile per configured channel, titled + `#channel`. Messages arrive with avatars, names and times, Slack's inline + formatting and the workspace's custom emoji; text size and jump-to-latest + sit in the title bar. Sending is the **Send Message** module's: a click + anywhere in a transcript points its tiles at that channel (the bottom + edge lights up in the accent color), and a message then leaves through + this module's one Slack connection, as the location. A right-click on a + message opens the reaction menu, which rides on the same unlock — with + the page locked, or no Send message tile on it, it says so instead of + reacting. This module has no PIN, send tile or quick replies of its own. + Admin config is in three groups: **Slack account** (the location account's User OAuth Token — `xoxp-…`, so messages are literally from the campus — or a bot token plus "Send as"), **Channels** (the joined channels to - follow, one transcript tile each), **Transcript** (refresh interval, - messages kept, thread replies) and **Sending** (the switch on the group's - title row, a 4–8 digit PIN, auto-lock minutes, quick replies one per line - as `Label | Message` — add `| #channel` to bind one to a channel — and up - to eight quick emojis). + follow, one transcript tile and one Send Message destination each) and + **Transcript** (refresh interval, messages kept, thread replies). Tile settings: avatars, timestamps, names and channel notices. +- **Send Message** — the one place a dashboard sends from, whatever the + platform. Two tiles, one of each per dashboard: **Send message**, a + free-text box with the admin's quick emojis one press away, and **Quick + replies**, a grid of preset buttons. Both go wherever the operator last + pointed them with a click on a Slack or ProdCom transcript. A platform + whose destinations are identities rather than recipients — ProdCom, where + typed text goes out *as* a channel — also gets a **Send as** menu in the + title bar listing those identities; the menu exists only while such a + platform is installed, and Slack's channels are never in it. The choice is + saved with the tile, so a named layout remembers it; the transcript it + points at carries the accent stripe. The module + owns no connection of its own — it finds every installed module that + offers messaging and hands the message to it over loopback — and it owns + the PIN: nothing leaves the machine until it is entered on one of its + tiles, one entry unlocks every messaging tile on that dashboard page + (Slack's reaction menu included) until the auto-lock idles out, a reload + asks again, and the lock button ends it early. The PIN pad takes taps, a + keyboard, or a phone's — tapping the dots raises it. The PIN can be + switched off for a booth that doesn't need one. + Admin config: **PIN** (the switch on the group's title row, 4–8 digits, + auto-lock minutes) and **Presets** (quick replies one per line as + `Label | Message` — add `| slack:#channel` or `| prodcom:Channel` to bind + one to a destination — and up to eight quick emojis). + Tile settings: quick emojis on the Send message tile. - **Resi Cues** — how long until the next Resi cue point, for a campus running video from another site. The tile is one number, as large as it will go: the countdown to the next cue, its name above it, and the playhead @@ -484,7 +508,8 @@ below. Nothing is installed without someone clicking. reference for module authors. To develop modules against mocks instead of real gear: -`node tools/prodcom-mock.js` (ProdCom on `127.0.0.1:24480`), +`node tools/prodcom-mock.js` (ProdCom on `127.0.0.1:24480`, including the +send route the Send Message module uses), `node band-lineup-display/tmp/pp-mock.js 1599` (ProPresenter slides on `127.0.0.1:1599`), `node tools/pp-timers-mock.js 1600` (ProPresenter timers + timecode on `127.0.0.1:1600`), `node tools/pco-mock.js` diff --git a/docs/MODULE-GUIDE.md b/docs/MODULE-GUIDE.md index 1429df2..74256db 100644 --- a/docs/MODULE-GUIDE.md +++ b/docs/MODULE-GUIDE.md @@ -202,7 +202,7 @@ line, so a Windows booth knows why the feature is off. | `proddash` | yes | The ProdDash versions this module works with: `">=1.1.0"`, `"^1.1.0"`, `">=1.1.0 <2.0.0"`, or `"*"`. A module whose requirement the running shell doesn't meet is listed in the admin page but never loaded, and can't be installed from the catalog. | | `description` | no | Shown in the admin page / picker. | | `permissions` | no | What macOS must allow before this module can work — see **Permissions macOS has to grant**. Ignored by the shell; read by the macOS launcher. | -| `provides` | no | Capabilities other modules can consume — `["timers"]` today. Forwarded in `/api/modules` so a consumer can find you. See **Working with other modules**. | +| `provides` | no | Capabilities other modules can consume — `["timers"]`, `["messaging"]`. Forwarded in `/api/modules` so a consumer can find you. See **Working with other modules**. | | `client` | yes | Client entry file, loaded as an ES module. | | `server` | no | Server entry file (CommonJS), loaded with `require()`. | | `style` | no | A stylesheet the shell injects once per module. | @@ -770,6 +770,108 @@ comment every 15 s); the consumer uses the stream when it exists and polls wall — the Timers module lets them hide any of them per tile, but every entry you add is a decision someone has to make. +### Offering messaging to the Send Message module + +The Send Message module is the one place a dashboard sends from — a +PIN-gated compose box and a grid of quick replies — whatever the platform. +It owns nothing upstream: a module that *can* deliver a message (Slack posts +to a channel, ProdCom puts typed text on a comms channel) offers that over +its own routes, keeps its one upstream connection, and the Send Message +module hands messages to it over loopback. A provider has no PIN, no send +tile and no quick replies of its own; the shell's Add-tile picker shows the +Send message and Quick replies tiles once, under Send Message. + +A module offers messaging by: + +1. Declaring it — `"provides": ["messaging"]` in `module.json`. The Send + Message module re-reads `/api/modules` every 30 s, so a provider installed + mid-session shows up without a restart. +2. Serving `GET /messaging` — what it can send to, from state it already + holds (it is polled every few seconds while a send tile is open): + +```json +{ + "label": "Slack", + "identity": "Apollo Beach", + "destinationRole": "recipient", + "status": { "state": "ok", "message": "Apollo Beach · #production, #ops" }, + "destinations": [ + { "id": "C01ABC", "label": "#production" }, + { "id": "C02DEF", "label": "#ops", "color": "#5c9de0" } + ], + "emptyMessage": "", + "maxChars": 4000 +} +``` + +| Field | Meaning | +| --- | --- | +| `label` | The platform's name, as the sending line and the Send as menu show it. | +| `identity` | Who a message appears to come from when that is fixed by the connection, or `''`. Shown as "as Apollo Beach". | +| `destinationRole` | What a destination *is*. `recipient` (the default): a message goes **to** it — a Slack channel — and the operator picks one by clicking its transcript tile, so it never appears in the Send as menu. `identity`: a message goes out **as** it — a ProdCom channel — and the Send as menu lists them; the tiles then say "Send message to ProdCom as FOH" and "Sent to ProdCom as FOH". The menu exists only while some installed platform offers identities. | +| `status` | `ok`, `connecting` or `error` plus one line — the same thing your `health()` says. A provider in error is still listed, so the operator sees why a send will fail before trying. | +| `destinations` | Where a message can go, or who it can go out as: a stable `id` (what `/messaging/send` receives), a `label`, and an optional `color`. Offer only what will work — ProdCom lists just the channels that accept typed text. Order is the order the menu shows. | +| `emptyMessage` | Optional: why the list is empty, when you know ("No ProdCom channel accepts typed text — turn on typing for one in ProdCom"). The tiles show it in place of a destination. | +| `maxChars` | The longest text you accept; the Send Message module refuses longer ones before asking you. | + +3. Serving `POST /messaging/send` — `{ "destination": "", "text": "…" }` + with the browser's unlock token as `Authorization: Bearer …`. **Check the + token before touching your upstream**: the Send Message module owns the + PIN, so ask it — `GET http://127.0.0.1:${shell.port}/api/modules/send-message/verify` + with the same `Authorization` header answers `200 { ok: true, expiresAt }` + or `401`. A `404` means no Send Message module is installed or enabled: + refuse with a sentence that says so. Without this check, any browser on + the network could post as the location by calling your route directly. + Answer `200 { "ok": true, "label": "#ops" }` when it landed, otherwise + `{ "error": "", "code": "…" }` with `400` + (bad destination or text), `401` (the token was refused), `429` (your + upstream is rate-limiting) or `502` (your upstream failed). Validate the + destination against your own list — never pass an arbitrary id upstream. + +The same verification gates any other outbound action a module offers from +its tiles — the Slack transcript's emoji reactions ride on it — so a module +never needs a PIN of its own: one unlock serves every messaging tile on the +page. The `slack` and `prodcom-transcript` modules are the reference +implementations (`verifyUnlock()` in either server entry is the whole check). + +### Messaging on the page + +Where the next message goes is a fact about the whole dashboard, not one +tile: the Send message tile, the Quick replies tile and every transcript on +the page must agree, and they belong to different modules. Modules don't +import each other, so the agreement lives on `window` — one plain object plus +`CustomEvent`s on `window`, created by whichever messaging-aware tile mounts +first: + +```js +window.proddashMessaging = { + version: 1, + target: { provider: '', destination: '', providerLabel: '', destinationLabel: '', identity: '' }, + unlock: { token: '', expiresAt: 0, reason: '' }, + quickEmojis: [], // the Send Message module's admin list, plain strings + transcripts: new Map(), // instanceId → { provider, destination } + senders: new Set(), // instanceIds of Send message / Quick replies tiles +}; +``` + +Events, all named `proddash:messaging:` with the payload in `detail`: + +| Event | Who sends it | Meaning | +| --- | --- | --- | +| `select` | a transcript tile, or a Send as menu | `{ provider, destination }` — the operator pointed the senders here. A tile bound to one recipient (a Slack channel transcript) names it; one that shows a whole platform (ProdCom) sends `destination: ''` and the Send Message tile keeps the identity it has chosen for that platform. | +| `target` | the Send Message tiles | `window.proddashMessaging.target` changed: where the next message goes, resolved. A transcript compares `provider` (and `destination`, when it has one) and lights its bottom edge. | +| `unlock` | the Send Message tiles; a provider's tile on a `401` | `window.proddashMessaging.unlock` changed. Read the token from the object, never cache it; `''` means locked, with `reason` saying why. | +| `transcripts` | transcript tiles | the `transcripts` map changed (a tile mounted, unmounted or learned its channel). Register once you know your destination; unregister in `stop()`. This is how a dashboard with one transcript sends there by default. | +| `senders` | the Send Message tiles | the `senders` set changed — a transcript's locked note can say "add a Send message tile" rather than "enter the PIN". | + +A transcript tile needs only three things: emit `select` on a click in its +body, register in `transcripts`, and toggle a target class on `target`. A +tile that performs a gated action itself (a reaction) reads +`window.proddashMessaging.unlock.token` and sends it as the Bearer token to +its own module's route. The Send Message tiles save the chosen platform and +per-platform destination in their instance settings, so a named layout +remembers them; the page object is the live copy. + ## Complete minimal example A working clock — copy-paste this as your starter. (A slightly fancier @@ -863,8 +965,16 @@ client-side failures (a tile that fails to start says so in the tile). **Mock upstreams** (so you can develop without the real gear): - ProdCom: `node tools/prodcom-mock.js` (port 24480) — channels, groups, - history, and a live stream with in-progress speech. Point the - transcript module's ProdCom URL at `http://127.0.0.1:24480`. + history, a live stream with in-progress speech, and + `POST /api/v1/channels/{id}/send`, which lands typed text in the channel + as a `source: "typed"` entry (so a send from the Send Message module can + be watched arriving; a text containing `!429` answers RATE_LIMITED, and + `PRODCOM_MOCK_KEY=…` makes it demand that API key). Point the transcript + module's ProdCom endpoint at `127.0.0.1`, port `24480`. +- Send Message: no mock — it talks to the installed modules. Run the Slack + and ProdCom mocks, set a PIN under Send Message in `/admin` (or switch the + PIN off), add a transcript or two plus Send message and Quick replies from + +, and watch a send arrive in the transcript you clicked. - ProPresenter: `node band-lineup-display/tmp/pp-mock.js 1599` — playlists and an advancing slide index (including the arrangement-repeat case). Point the Now/Next module at host `127.0.0.1`, port `1599`. diff --git a/modules/prodcom-transcript/client.js b/modules/prodcom-transcript/client.js index 95dadf8..75797af 100644 --- a/modules/prodcom-transcript/client.js +++ b/modules/prodcom-transcript/client.js @@ -5,7 +5,45 @@ (hidden channels, channel icons, timestamps, text size, flow direction) persists in instance settings, and the data comes from the module's own server, which keeps one connection to ProdCom for every dashboard: a - snapshot on connect (`state`), then each changed entry (`entry`). */ + snapshot on connect (`state`), then each changed entry (`entry`). + + Messaging: a click anywhere in the tile makes ProdCom the platform the + Send Message module's tiles send through (the channel the text goes out + *as* is picked in their Send as menu, among the channels ProdCom lets text + be typed onto); while it is, the tile's bottom edge carries the accent + stripe. + That is shared through the page bus below — see "Messaging on the page" + in docs/MODULE-GUIDE.md. Text typed from a dashboard comes back on the + stream as an entry with source "typed" and is drawn with a keyboard mark. */ + +/* ── the page bus (shared with the Send Message module) ───────────── */ + +const PROVIDER_ID = 'prodcom-transcript'; +const BUS_EVENT = 'proddash:messaging:'; + +function messagingBus() { + if (!window.proddashMessaging) { + window.proddashMessaging = { + version: 1, + target: { provider: '', destination: '', providerLabel: '', destinationLabel: '', identity: '' }, + unlock: { token: '', expiresAt: 0, reason: '' }, + quickEmojis: [], + transcripts: new Map(), + senders: new Set(), + }; + } + return window.proddashMessaging; +} + +function emit(name, detail = {}) { + window.dispatchEvent(new CustomEvent(BUS_EVENT + name, { detail })); +} + +function on(name, fn) { + const handler = (e) => fn(e.detail || {}); + window.addEventListener(BUS_EVENT + name, handler); + return () => window.removeEventListener(BUS_EVENT + name, handler); +} /* the text-size buttons: a drawn − and + at the icon size, like every other header icon */ const MINUS_SVG = ''; @@ -21,6 +59,8 @@ export default function create({ root, moduleApi }) { let hiddenChannels = new Set(moduleApi.instanceSettings.hiddenChannels || []); let stream = null; // moduleApi.sse handle let stopped = false; + let lastStatus = null; // the link's last status, re-reported when the target changes + const bus = messagingBus(); /* ── DOM skeleton ───────────────────────────────────────────────── */ @@ -39,6 +79,26 @@ export default function create({ root, moduleApi }) { const emptyEl = root.querySelector('.pt-empty'); const jumpBtn = root.querySelector('.pt-jump'); + /* ── the send target: a click anywhere in the body points the senders here ── */ + + wrap.addEventListener('click', () => { + if (!channels.size) return; // nothing to send to yet + emit('select', { provider: PROVIDER_ID, destination: '' }); + }); + + function isTarget() { + return bus.target.provider === PROVIDER_ID; + } + + function paintTarget() { + if (stopped) return; + const on = isTarget(); + if (on === wrap.classList.contains('is-target')) return; + wrap.classList.toggle('is-target', on); + if (lastStatus) applyStatus(lastStatus); + } + const offTarget = on('target', paintTarget); + /* ── title-bar controls ─────────────────────────────────────────── */ const CLOCK_SVG = ''; @@ -386,10 +446,18 @@ export default function create({ root, moduleApi }) { const avatar = document.createElement('span'); avatar.className = 'avatar'; avatar.style.color = info.color; - avatar.innerHTML = - ''; + // speech is a person; typed text (from a dashboard, or ProdCom's own + // box) a keyboard; an automation a bolt + avatar.innerHTML = entry.source === 'typed' + ? '' + : entry.source === 'automation' + ? '' + : ''; + if (entry.source === 'typed' || entry.source === 'automation') el.classList.add(entry.source); const ch = document.createElement('span'); ch.className = 'channel'; @@ -434,14 +502,27 @@ export default function create({ root, moduleApi }) { function applyStatus(st) { if (!st || typeof st !== 'object') return; - if (st.state === 'ok') moduleApi.setStatus('ok', st.message || 'Live'); - else moduleApi.setStatus(st.state === 'error' ? 'error' : 'connecting', st.message || 'Connecting to ProdCom…'); + lastStatus = st; + if (st.state === 'ok') { + // the Send Message tiles point here: typed text goes out as the chosen channel + const as = !isTarget() ? '' + : bus.target.destinationLabel ? ` · sends go out as ${bus.target.destinationLabel}` + : ' · selected — no channel accepts typed text'; + moduleApi.setStatus('ok', (st.message || 'Live') + as); + } else moduleApi.setStatus(st.state === 'error' ? 'error' : 'connecting', st.message || 'Connecting to ProdCom…'); } function applySnapshot(snap) { if (!snap || typeof snap !== 'object') return; channels.clear(); for (const ch of snap.channels || []) channels.set(String(ch.id), { name: ch.name, color: ch.color || '#8b98a5' }); + // On the page and able to send: that is what lets the Send Message tiles + // pick ProdCom by default when it is the only transcript showing. + if (channels.size && !bus.transcripts.has(moduleApi.instanceId)) { + bus.transcripts.set(moduleApi.instanceId, { provider: PROVIDER_ID, destination: '' }); + emit('transcripts', {}); + } + wrap.classList.toggle('is-target', isTarget()); groups.clear(); for (const g of snap.groups || []) groups.set(String(g.id), { name: g.name, channelIds: g.channelIds || [] }); rebuildChannelMenu(); @@ -493,6 +574,8 @@ export default function create({ root, moduleApi }) { stop() { stopped = true; sizeWatch.disconnect(); + offTarget(); + if (bus.transcripts.delete(moduleApi.instanceId)) emit('transcripts', {}); stream?.close(); root.innerHTML = ''; // header controls are removed by the shell }, diff --git a/modules/prodcom-transcript/module.json b/modules/prodcom-transcript/module.json index d5ee214..af43546 100644 --- a/modules/prodcom-transcript/module.json +++ b/modules/prodcom-transcript/module.json @@ -1,15 +1,18 @@ { "id": "prodcom-transcript", "name": "ProdCom", - "version": "1.3.3", - "proddash": ">=1.1.0", - "description": "Live ProdCom transcript", + "version": "1.4.0", + "proddash": ">=1.4.0", + "description": "Live ProdCom transcript; typed text to a ProdCom channel through the Send Message module", "permissions": [ { "kind": "local-network", "reason": "Reaching the ProdCom API on this network" } ], + "provides": [ + "messaging" + ], "client": "client.js", "server": "server.js", "style": "style.css", @@ -29,7 +32,7 @@ "host": "", "port": 24480 }, - "help": "The ProdCom server at THIS location — every campus has its own. Blank until you set it, so a new install never reads another campus's comms." + "help": "The ProdCom server at THIS location — every campus has its own. Blank until you set it, so a new install never reads another campus’s comms. The Send Message module sends typed text to these channels through this connection." }, "apiKey": { "type": "password", diff --git a/modules/prodcom-transcript/server.js b/modules/prodcom-transcript/server.js index 73b40f0..93dd26e 100644 --- a/modules/prodcom-transcript/server.js +++ b/modules/prodcom-transcript/server.js @@ -11,19 +11,36 @@ * connections; ProdCom now sees one.) * * Routes (under /api/modules/prodcom-transcript): - * GET /state → { status, channels, groups, entries } (entries by date) - * GET /stream → SSE: `state` (the snapshot, first), then `entry` for each - * changed entry, `clear`, and `status` when the link changes + * GET /state → { status, channels, groups, entries } (entries by date) + * GET /stream → SSE: `state` (the snapshot, first), then `entry` for each + * changed entry, `clear`, and `status` when the link changes + * GET /messaging → what the Send Message module may send as: every + * channel that accepts typed text (typingEnabled), as + * an identity — typed text goes out *as* that channel + * (the module guide's contract) + * POST /messaging/send → { destination, text } with the Send Message unlock + * as a Bearer token — checked with that module over + * loopback, then POST /api/v1/channels/{id}/send, so a + * dashboard's typed text goes out on a ProdCom channel + * over this one connection. This module has no PIN and + * no send tile of its own. * * ProdCom's API, as the reference listener speaks it: * GET /api/v1/channels, /api/v1/groups, /api/v1/transcript?limit=N and the * SSE /api/v1/transcript/stream, whose frames carry an entry, or a * { type, entry | data } wrapper, in a few historical shapes (see - * extractEntries). A bearer API key is sent when one is configured. + * extractEntries). POST /api/v1/channels/{id}/send with { text } puts typed + * text on a channel (it comes back on the stream with source "typed"). A + * bearer API key is sent when one is configured. */ const http = require('http'); +/** The module that owns the PIN and the unlock tokens — see verifyUnlock(). */ +const SENDER_ID = 'send-message'; +const MAX_TEXT_CHARS = 2000; +const BODY_LIMIT_BYTES = 64 * 1024; + const HISTORY_LIMIT = 100; // entries asked for on (re)connect const ENTRIES_MAX = 300; // entries kept in memory const RETRY_MIN_MS = 2000; @@ -67,13 +84,92 @@ function normalizeEntry(e) { text: String(e.text ?? ''), date: e.date ? String(e.date) : new Date().toISOString(), inProgress: Boolean(e.inProgress), + // audio (speech), typed (a dashboard or ProdCom's own box), automation + source: e.source === 'typed' || e.source === 'automation' ? String(e.source) : 'audio', }; } +/* ── 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] : ''; +} + +/** + * 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. + */ +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' }; + } + if (res.status === 404) return { ok: false, status: 503, error: 'Install the Send Message module to send 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 }; +} + let current = null; // the running instance, for routes() module.exports = { - init({ config, log }) { + init({ config, log, shell }) { + const shellPort = Number(shell && shell.port) || 0; const target = resolveTarget(config); const apiKey = String(config.apiKey || ''); const channels = []; @@ -148,7 +244,14 @@ module.exports = { getJson('/api/v1/channels'), getJson('/api/v1/groups').catch(() => ({ data: [] })), // groups are optional in ProdCom ]); - channels.splice(0, channels.length, ...(ch.data || []).map((c) => ({ id: String(c.id), name: String(c.name || c.id), color: c.color || '#8b98a5' }))); + channels.splice(0, channels.length, ...(ch.data || []).map((c) => ({ + id: String(c.id), + name: String(c.name || c.id), + color: c.color || '#8b98a5', + // whether ProdCom lets text be typed onto this channel (an older + // ProdCom that doesn't say is taken at its word when asked) + typingEnabled: c.typingEnabled !== false, + }))); groups.splice(0, groups.length, ...(gr.data || []).map((g) => ({ id: String(g.id), name: String(g.name || g.id), channelIds: Array.isArray(g.channelIds) ? g.channelIds.map(String) : [] }))); } @@ -264,9 +367,75 @@ module.exports = { log('no ProdCom server configured — set the endpoint in /admin'); } + /** A channel by id, or by name (case-insensitive) — ProdCom accepts both, the tiles use ids. */ + function channelByRef(ref) { + const s = String(ref ?? '').trim(); + if (!s) return null; + return channels.find((c) => c.id === s) || channels.find((c) => c.name.toLowerCase() === s.toLowerCase()) || null; + } + + /** + * What the Send Message module may send as: the channels that accept + * typed text. A ProdCom channel is an identity, not a recipient — text + * typed onto it goes out *as* that channel — so the destinations are + * declared as such, and a channel with typing off is left out. + */ + function messagingDescriptor() { + const h = current ? current.health() : { status: 'connecting', message: 'Restarting…' }; + return { + label: 'ProdCom', + identity: '', + destinationRole: 'identity', + status: { state: h.status, message: h.message }, + destinations: channels.filter((c) => c.typingEnabled).map((c) => ({ id: c.id, label: c.name, color: c.color || '' })), + emptyMessage: channels.length + ? 'No ProdCom channel accepts typed text — turn on typing for one in ProdCom' + : 'No ProdCom channels yet', + maxChars: MAX_TEXT_CHARS, + }; + } + + /** Typed text onto a channel: the one ProdCom connection does the talking. */ + async function sendText(ch, text) { + if (!target) return { status: 502, error: 'No ProdCom server configured — set the endpoint in Admin → ProdCom', code: 'no_target' }; + let res; + try { + res = await fetch(new URL(`/api/v1/channels/${encodeURIComponent(ch.id)}/send`, target), { + method: 'POST', + headers: { 'content-type': 'application/json; charset=utf-8', accept: 'application/json', ...authHeaders() }, + body: JSON.stringify({ text }), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + } catch (err) { + return { status: 502, error: `ProdCom unreachable (${err.cause?.code || err.code || err.message}) — not sent`, code: 'network' }; + } + if (res.ok) { + res.body?.cancel?.().catch?.(() => {}); + return { status: 200 }; + } + let detail = ''; + let code = ''; + try { + const body = await res.json(); + detail = String(body?.error?.message || ''); + code = String(body?.error?.code || ''); + } catch { /* no JSON */ } + const tail = detail ? ` — ${detail}` : ''; + if (res.status === 401) return { status: 502, error: `ProdCom rejected the API key (${code || 'UNAUTHORIZED'}) — check Admin → ProdCom`, code: 'unauthorized' }; + if (res.status === 404) return { status: 404, error: `ProdCom has no channel "${ch.name}" any more (${code || 'NOT_FOUND'}) — pick another in Send as`, code: 'not_found' }; + if (res.status === 429) return { status: 429, error: `ProdCom is rate-limiting — try again in a moment${tail}`, code: 'ratelimited' }; + if (res.status === 400 || res.status === 422) return { status: 400, error: `ProdCom refused the message (${code || res.status})${tail}`, code: 'refused' }; + return { status: 502, error: `ProdCom answered HTTP ${res.status}${tail}`, code: code || `http_${res.status}` }; + } + current = { snapshot, streams, + shellPort, + channelByRef, + messagingDescriptor, + sendText, + log, health() { if (status.state === 'ok') return { status: 'ok', message: `Live — ${channels.length} channel${channels.length === 1 ? '' : 's'}, ${entries.size} entries` }; // Nothing to connect to is a setting that is missing, not a link that @@ -295,15 +464,52 @@ module.exports = { }, routes() { - const sendJson = (res, code, body) => { - res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }); - res.end(JSON.stringify(body)); - }; + const unmounted = (res) => sendJson(res, 503, { error: 'ProdCom module is restarting — try again' }); return { 'GET /state': (req, res) => { - if (!current) return sendJson(res, 503, { error: 'ProdCom module is restarting — try again' }); + if (!current) return unmounted(res); sendJson(res, 200, current.snapshot()); }, + + // The Send Message module reads this over loopback: where it may send. + 'GET /messaging': (req, res) => { + if (!current) return unmounted(res); + sendJson(res, 200, current.messagingDescriptor()); + }, + + // …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 goes out on a channel. + 'POST /messaging/send': guard(async (req, res) => { + if (!current) return unmounted(res); + 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 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 ch = current.channelByRef(body.destination); + if (!ch) { + const s = String(body.destination ?? '').trim(); + return sendJson(res, 400, { error: s ? `No ProdCom channel "${s}" — pick another in Send as` : 'Which ProdCom channel?', code: 'bad_destination' }); + } + if (!ch.typingEnabled) { + return sendJson(res, 400, { error: `${ch.name} doesn't accept typed text — turn on typing for it in ProdCom`, code: 'typing_off' }); + } + const out = await current.sendText(ch, text); + if (out.status !== 200) { + current.log(`send to ${ch.name} failed: ${out.error}`); + return sendJson(res, out.status, { error: out.error, code: out.code }); + } + const preview = text.replace(/\s+/g, ' ').slice(0, 60); + current.log(`sent to ${ch.name}: "${preview}${text.length > 60 ? '…' : ''}"`); + sendJson(res, 200, { ok: true, label: ch.name, expiresAt: v.expiresAt }); + }), 'GET /stream': (req, res) => { res.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8', diff --git a/modules/prodcom-transcript/style.css b/modules/prodcom-transcript/style.css index 0867c40..9cc3e62 100644 --- a/modules/prodcom-transcript/style.css +++ b/modules/prodcom-transcript/style.css @@ -9,6 +9,28 @@ flex-direction: column; } +/* ── the send target ── + A click anywhere in the tile makes ProdCom the platform the Send Message + tiles send to. While it is, a 3px accent stripe runs 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. + (The same mark as the Slack transcript's.) */ +[data-module="prodcom-transcript"] .pt-wrap::after { + content: ""; + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 3px; + background: var(--accent); + opacity: 0; + pointer-events: none; + z-index: 3; + transition: opacity 0.15s ease; +} +[data-module="prodcom-transcript"] .pt-wrap:hover::after { opacity: 0.3; } +[data-module="prodcom-transcript"] .pt-wrap.is-target::after { opacity: 1; } + /* ── channels button ── Icon-only; the count appears next to it while some channels are hidden. */ @@ -149,6 +171,15 @@ overflow-wrap: break-word; } +/* typed text (from a dashboard, or ProdCom's own box) and automations carry + their own mark in the picture slot; the text reads the same */ +[data-module="prodcom-transcript"] .pt-entry.typed .avatar, +[data-module="prodcom-transcript"] .pt-entry.automation .avatar { + background: color-mix(in srgb, var(--ch, var(--muted)) 10%, transparent); + outline: 1px dashed color-mix(in srgb, var(--ch, var(--muted)) 55%, transparent); + outline-offset: -1px; +} + [data-module="prodcom-transcript"] .pt-entry.in-progress .text { color: var(--muted); } [data-module="prodcom-transcript"] .pt-entry.in-progress .text::after { content: "▍"; diff --git a/modules/send-message/client.js b/modules/send-message/client.js new file mode 100644 index 0000000..411d7a9 --- /dev/null +++ b/modules/send-message/client.js @@ -0,0 +1,1189 @@ +/* Send Message — client part. + + Two tiles (moduleApi.variant / the tile's `view` setting pick): a + PIN-gated free-text sender and a PIN-gated grid of preset replies. Both + send through this module's server, which hands the message to whichever + messaging module — Slack, ProdCom, … — the operator pointed them at by + clicking its transcript. A platform whose destinations are identities + (ProdCom: typed text goes out *as* a channel) also gets the Send as menu + in the title bar, where that identity is picked; a platform whose + destinations are recipients (Slack: a message goes *to* a channel) is + targeted by its transcripts alone and stays out of the menu, which is + hidden altogether when no installed platform offers identities. The + choice is saved with the tile (so a named layout remembers it) and shared + with every other messaging tile on the page through the bus below, so + Send message, Quick replies and the transcripts always agree on where the + next message goes. + + Everything lives inside the tile's root; the PIN never leaves the server + except as the token it hands back. */ + +/* ── shared helpers ─────────────────────────────────────────────────── */ + +function el(tag, cls, text) { + const node = document.createElement(tag); + if (cls) node.className = cls; + if (text !== undefined) node.textContent = text; + return node; +} + +async function readJson(res) { + try { + return await res.json(); + } catch { + return {}; + } +} + +const NO_PROVIDERS = 'No messaging module yet — install Slack or ProdCom from Admin → Available modules'; + +const LOCK_SVG = ''; +const BACK_SVG = ''; +/* feedback after a send: it landed / it did not */ +const CHECK_SVG = ''; +const BLOCKED_SVG = ''; +/* where sends go: a crosshair */ +const TARGET_SVG = ''; + +/* ── the page bus ───────────────────────────────────────────────────── */ + +/* One fact set about the whole page, not one tile: where the next message + goes, whether the page is unlocked, which transcripts are on it. Shared + with the Slack and ProdCom modules (modules don't import each other) as + a plain object on `window` plus CustomEvents on it — the contract is in + docs/MODULE-GUIDE.md under "Messaging on the page". Whoever mounts first + creates it. */ +const BUS_EVENT = 'proddash:messaging:'; + +function messagingBus() { + if (!window.proddashMessaging) { + window.proddashMessaging = { + version: 1, + target: { provider: '', destination: '', providerLabel: '', destinationLabel: '', identity: '' }, + unlock: { token: '', expiresAt: 0, reason: '' }, + quickEmojis: [], + transcripts: new Map(), // instanceId → { provider, destination } + senders: new Set(), // instanceIds of Send message / Quick replies tiles on the page + }; + } + return window.proddashMessaging; +} + +function emit(name, detail = {}) { + window.dispatchEvent(new CustomEvent(BUS_EVENT + name, { detail })); +} + +function on(name, fn) { + const handler = (e) => fn(e.detail || {}); + window.addEventListener(BUS_EVENT + name, handler); + return () => window.removeEventListener(BUS_EVENT + name, handler); +} + +/** The page's unlock token, '' once it has expired. */ +function pageToken(bus) { + return bus.unlock.token && bus.unlock.expiresAt > Date.now() ? bus.unlock.token : ''; +} + +/* ── the PIN pad ────────────────────────────────────────────────────── */ + +/** + * 1–9, 0 and ⌫ under a row of dots, one per digit of the PIN. The dots sit + * on a real (invisible) numeric input, so a tap on them raises a phone's + * keyboard and a desktop keyboard types straight in; the keys are for a + * touch screen without one. It submits by itself once as many digits as the + * PIN has are in (the server says how many, never which), or at eight. On + * success the page-wide unlock is set and `onUnlocked` runs. + */ +function createPinPad({ moduleApi, bus, heading = 'Enter PIN', subtitle, pinLength, onUnlocked }) { + let busy = false; + let error = ''; + let note = ''; + const pad = el('div', 'sm-pad'); + const head = el('div', 'sm-pad-head'); + const sub = el('span', 'sm-pad-sub'); + head.append(el('b', '', heading), sub); + + const field = el('div', 'sm-field'); + const dots = el('div', 'sm-dots'); + for (let i = 0; i < 8; i += 1) dots.appendChild(el('span', 'sm-dot')); + const input = el('input', 'sm-pin-input'); + input.type = 'text'; + input.inputMode = 'numeric'; + input.autocomplete = 'one-time-code'; + input.setAttribute('pattern', '[0-9]*'); + input.setAttribute('aria-label', 'PIN'); + input.maxLength = 8; + input.spellcheck = false; + input.autocapitalize = 'off'; + field.append(dots, input); + field.title = 'Tap to type the PIN'; + + const keys = el('div', 'sm-keys'); + const key = (label, cls, onClick, html) => { + const b = el('button', 'sm-key' + (cls ? ' ' + cls : '')); + b.type = 'button'; + b.tabIndex = -1; // the input keeps the focus; keys are taps + if (html) b.innerHTML = html; // our own SVG constants only + else b.textContent = label; + b.addEventListener('pointerdown', (e) => e.preventDefault()); // don't steal the input's focus + b.addEventListener('click', (e) => { + e.preventDefault(); + onClick(); + }); + return b; + }; + for (const d of ['1', '2', '3', '4', '5', '6', '7', '8', '9']) keys.appendChild(key(d, '', () => press(d))); + keys.appendChild(el('span', 'sm-key sm-key-blank')); + keys.appendChild(key('0', '', () => press('0'))); + const back = key('⌫', 'sm-key-back', backspace, BACK_SVG); + back.title = 'Backspace'; + keys.appendChild(back); + const msg = el('div', 'sm-pad-msg'); + // Shown instead of the keys when the tile is too short for them (style.css). + const hint = el('div', 'sm-pad-hint', 'Tap the dots and type the PIN, or make the tile taller for a keypad.'); + pad.append(head, field, keys, hint, msg); + + const wanted = () => Math.min(8, Math.max(0, Math.trunc(Number(pinLength?.()) || 0))); + const value = () => input.value; + + function refresh() { + const text = subtitle ? String(subtitle() || '') : ''; + sub.textContent = text ? ` · ${text}` : ''; + const n = Math.min(8, value().length); + // One dot per digit of the PIN; while its length is unknown, 4 and one spare. + const slots = wanted() || Math.max(4, n + (n < 8 ? 1 : 0)); + [...dots.children].forEach((d, i) => { + d.classList.toggle('on', i < n); + d.hidden = i >= slots; + }); + pad.classList.toggle('is-busy', busy); + input.disabled = busy; + msg.textContent = error || note; + msg.classList.toggle('is-error', Boolean(error)); + } + + function setValue(v) { + input.value = String(v || '').replace(/\D/g, '').slice(0, 8); + refresh(); + } + + function maybeSubmit() { + const len = wanted(); + if ((len && value().length >= len) || value().length === 8) submit(); + } + + function press(d) { + if (busy || value().length >= 8) return; + error = ''; + setValue(value() + d); + maybeSubmit(); + } + + function backspace() { + if (busy) return; + error = ''; + setValue(value().slice(0, -1)); + } + + input.addEventListener('input', () => { + if (busy) return; + error = ''; + setValue(input.value); + maybeSubmit(); + }); + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + submit(); + } else if (e.key === 'Escape') { + e.preventDefault(); + error = ''; + setValue(''); + } + }); + field.addEventListener('click', () => input.focus()); + + function shake(message) { + error = message; + note = ''; + setValue(''); + pad.classList.remove('sm-shake'); + void pad.offsetWidth; // restart the animation + pad.classList.add('sm-shake'); + } + + async function submit() { + if (busy || value().length < 4) return; + const pin = value(); + busy = true; + refresh(); + try { + const res = await moduleApi.fetch('/unlock', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ pin }), + }); + const body = await readJson(res); + if (!res.ok || !body.token) { + shake(body.error || `Could not unlock (HTTP ${res.status})`); + return; + } + error = ''; + note = ''; + setValue(''); + bus.unlock = { token: String(body.token), expiresAt: Number(body.expiresAt) || Date.now() + 10 * 60000, reason: '' }; + emit('unlock', bus.unlock); + onUnlocked?.(body); + } catch { + shake('ProdDash unreachable — try again'); + } finally { + busy = false; + refresh(); + } + } + + /** Keys that reach the tile rather than the input: digits, Backspace, Enter, Escape. */ + function onKey(e) { + if (e.target === input) return false; + if (/^[0-9]$/.test(e.key)) press(e.key); + else if (e.key === 'Backspace') backspace(); + else if (e.key === 'Enter') submit(); + else if (e.key === 'Escape') { + error = ''; + setValue(''); + } else return false; + e.preventDefault(); + return true; + } + + refresh(); + return { + el: pad, + refresh, + onKey, + focus() { + input.focus({ preventScroll: true }); + }, + setNote(text) { + note = String(text || ''); + error = ''; + refresh(); + }, + }; +} + +/* ── the factory ────────────────────────────────────────────────────── */ + +export default function create({ root, moduleApi }) { + const variant = String(moduleApi.variant || ''); + const view = String(moduleApi.instanceSettings.view || variant || 'send'); + return createSendTile({ root, moduleApi, mode: view === 'quick' ? 'quick' : 'send' }); +} + +/* ── send + quick replies ───────────────────────────────────────────── */ + +function createSendTile({ root, moduleApi, mode }) { + const bus = messagingBus(); + let token = pageToken(bus); // the page's unlock — memory only, gone on reload + let state = null; // latest snapshot: providers, pin flags, quick emojis, status + let stream = null; + let ticker = null; + let stopped = false; + let busy = false; + let autoUnlocking = false; + let gateNote = ''; // one line under the pad ("Locked after inactivity") + let gateError = ''; // the server's refusal ("Wrong PIN", "locked for 30 s") + let gateKind = ''; // what the gate currently shows, to avoid needless rebuilds + let presets = null; + let targetTimer = null; + + // The choice, saved with the tile: which platform, and per platform which + // destination — the Slack channel a message goes to, the ProdCom channel it + // goes out as. + let activeProvider = String(moduleApi.instanceSettings.provider || ''); + let chosen = { ...(moduleApi.instanceSettings.chosen && typeof moduleApi.instanceSettings.chosen === 'object' ? moduleApi.instanceSettings.chosen : {}) }; + + root.innerHTML = ` +
+
+ +
`; + const wrap = root.querySelector('.sm-wrap'); + const gate = root.querySelector('.sm-gate'); + const compose = root.querySelector('.sm-compose'); + + const lockBtn = moduleApi.header.addButton({ icon: LOCK_SVG, title: 'Lock', onClick: () => lock('Locked') }); + lockBtn.style.display = 'none'; + + /* ── where a send goes ── */ + function providers() { + return Array.isArray(state?.providers) ? state.providers : []; + } + + function providerById(id) { + return id ? providers().find((p) => p.id === id) || null : null; + } + + /** Destinations of `providerId` with a transcript on this page, in page order. */ + function shownDestinations(providerId) { + const out = []; + for (const t of bus.transcripts.values()) if (t.provider === providerId && t.destination) out.push(t.destination); + return out; + } + + /** + * The platform the operator chose (a click on its transcript, or the Send + * as menu) while it is still offered, else one with a transcript on this + * page, else the first; its destination the one chosen for it, else one + * with a transcript here, else its first. + */ + function resolveTarget() { + const list = providers(); + if (!list.length) return { provider: null, destination: null }; + let p = providerById(activeProvider); + if (!p) { + for (const t of bus.transcripts.values()) { + p = providerById(t.provider); + if (p) break; + } + if (!p) p = list[0]; + } + const dests = Array.isArray(p.destinations) ? p.destinations : []; + let d = dests.find((x) => x.id === chosen[p.id]) || null; + if (!d) { + const shown = shownDestinations(p.id); + d = dests.find((x) => shown.includes(x.id)) || dests[0] || null; + } + return { provider: p, destination: d }; + } + + /** Tell the page (transcripts light up, the other send tile follows). */ + function publishTarget() { + if (!state) return; + const { provider, destination } = resolveTarget(); + const next = { + provider: provider?.id || '', + destination: destination?.id || '', + providerLabel: provider?.label || '', + destinationLabel: destination?.label || '', + identity: provider?.identity || '', + }; + const cur = bus.target; + if (next.provider === cur.provider && next.destination === cur.destination && next.destinationLabel === cur.destinationLabel && next.identity === cur.identity) return; + bus.target = next; + emit('target', next); + } + + function persistChoice() { + moduleApi.saveInstanceSettings({ provider: activeProvider, chosen }); + } + + /** A transcript was clicked, or a destination picked from a Send as menu (ours or the other tile's). */ + function choose(provider, destination) { + if (!provider) return; + let changed = false; + if (activeProvider !== provider) { + activeProvider = provider; + changed = true; + } + if (destination && chosen[provider] !== destination) { + chosen = { ...chosen, [provider]: destination }; + changed = true; + } + if (changed) persistChoice(); + repaintTarget(); + } + + const isIdentity = (p) => p?.destinationRole === 'identity'; + + /** "Slack · as Apollo Beach → #ops" / "ProdCom · as FOH" — or why there is nowhere to send. */ + function targetLine() { + if (state && !providers().length) return NO_PROVIDERS; + const { provider, destination } = resolveTarget(); + if (!provider) return 'Connecting…'; + if (!destination) { + if (provider.emptyMessage) return provider.emptyMessage; + return isIdentity(provider) ? `${provider.label}: pick who to send as in Send as` : `${provider.label}: no destination yet`; + } + if (isIdentity(provider)) return `${provider.label} · as ${destination.label}`; + const who = provider.identity ? ` · as ${provider.identity}` : ''; + return `${provider.label}${who} → ${destination.label}`; + } + + /** "Send message to ProdCom as FOH…" / "Send message to #ops…" */ + function placeholder() { + const { provider, destination } = resolveTarget(); + if (!destination) return 'Send message…'; + return isIdentity(provider) + ? `Send message to ${provider.label} as ${destination.label}…` + : `Send message to ${destination.label}…`; + } + + /** Platforms whose destinations are identities — the only ones the Send as menu is for. */ + function identityProviders() { + return providers().filter(isIdentity); + } + + /** The selection changed somewhere on the page: every line that names the target follows. */ + function repaintTarget() { + if (stopped || !state) return; + publishTarget(); + if (token) { + if (composeEls) { + if (composeEls.line) composeEls.line.textContent = targetLine(); + if (composeEls.input) composeEls.input.placeholder = placeholder(); + } + } else renderGate(); + reportStatus(); + } + + const offSelect = on('select', ({ provider, destination }) => { + if (stopped) return; + choose(String(provider || ''), String(destination || '')); + }); + const offTranscripts = on('transcripts', () => { + // Coalesce: a remount detaches and re-attaches within a few ms. + clearTimeout(targetTimer); + targetTimer = setTimeout(repaintTarget, 50); + }); + // Another messaging tile (or Slack's reaction menu) unlocked or locked the page. + const offUnlock = on('unlock', () => { + if (stopped) return; + const t = pageToken(bus); + if (t && t !== token) { + token = t; + gateNote = ''; + gateError = ''; + showCompose(); + } else if (!t && token) lock(bus.unlock.reason || 'Locked', true); + }); + + /* ── the Send as menu: who a message goes out as, per platform that has identities ── + Slack is not here: a Slack channel is where a message goes, and that is + chosen by clicking its transcript. The button itself only exists while + some installed platform offers identities (see applyState). */ + const sendAsMenu = moduleApi.header.addMenu({ + icon: TARGET_SVG, + title: 'Send as', + build(menu) { + menu.classList.add('sm-menu'); + const list = identityProviders(); + if (!list.length) { + menu.appendChild(el('div', 'sm-menu-note', state ? 'No platform here sends as a chosen identity' : 'Connecting…')); + return; + } + const { provider: cur, destination: curD } = resolveTarget(); + for (const p of list) { + const head = el('div', 'sm-menu-head'); + head.appendChild(el('b', '', p.label)); + if (p.status?.state !== 'ok') { + head.classList.add(p.status?.state === 'error' ? 'is-error' : 'is-muted'); + head.title = p.status?.message || ''; + } + menu.appendChild(head); + const dests = Array.isArray(p.destinations) ? p.destinations : []; + if (!dests.length) { + menu.appendChild(el('div', 'sm-menu-note', p.status?.state === 'error' ? p.status.message : p.emptyMessage || 'Nothing to send as yet')); + continue; + } + for (const d of dests) { + const isOn = cur?.id === p.id && curD?.id === d.id; + const remembered = !isOn && chosen[p.id] === d.id; + const item = el('button', 'sm-menu-item' + (isOn ? ' on' : remembered ? ' remembered' : '')); + item.type = 'button'; + item.setAttribute('role', 'menuitemradio'); + item.setAttribute('aria-checked', String(isOn)); + const check = el('span', 'check', '✓'); + const name = el('span', 'name', d.label); + if (d.color) name.style.color = d.color; + item.append(check, name); + item.addEventListener('click', () => { + sendAsMenu.close(); + emit('select', { provider: p.id, destination: d.id }); + }); + menu.appendChild(item); + } + } + }, + }); + const sendAsWrap = sendAsMenu.button.parentElement; // the shell's .tile-dd + sendAsWrap.hidden = true; + + /** The Send as button is for platforms with identities; with none installed it isn't there at all. */ + function placeSendAsMenu() { + const show = identityProviders().length > 0; + if (sendAsWrap.hidden !== !show) { + sendAsWrap.hidden = !show; + if (!show) sendAsMenu.close(); + } + } + + function authHeaders(extra = {}) { + return { Authorization: `Bearer ${token}`, ...extra }; + } + + /* ── the gate: a PIN pad, or the reason there isn't one ── */ + let pinPad = null; + function renderGate() { + compose.hidden = true; + gate.hidden = false; + lockBtn.style.display = 'none'; + let kind; + if (!state) kind = 'connecting'; + else if (!providers().length) kind = 'noproviders'; + else if (!state.pin?.enabled) kind = 'auto'; + else if (!state.pin?.set) kind = 'nopin'; + else kind = 'pad'; + if (kind !== gateKind) { + gateKind = kind; + gate.textContent = ''; + pinPad = null; + if (kind === 'pad') { + pinPad = createPinPad({ + moduleApi, + bus, + subtitle: targetLine, + pinLength: () => Number(state?.pin?.length) || 0, + onUnlocked() { + token = pageToken(bus); + gateNote = ''; + gateError = ''; + showCompose(); + }, + }); + gate.appendChild(pinPad.el); + pinPad.focus(); + } else { + const note = el('div', 'sm-note'); + note.textContent = kind === 'connecting' ? 'Connecting to ProdDash…' + : kind === 'noproviders' ? NO_PROVIDERS + : kind === 'nopin' ? 'Set a PIN in Admin → Send Message, or switch the PIN off' + : 'Unlocking…'; + gate.appendChild(note); + } + } + if (kind === 'pad' && pinPad) { + pinPad.setNote(gateError || gateNote); + pinPad.refresh(); + } else if (kind === 'auto') { + const note = gate.querySelector('.sm-note'); + if (note && gateNote) note.textContent = gateNote; + autoUnlock(); + } + } + + /** The PIN is switched off: take a token without asking (one per page is enough). */ + async function autoUnlock() { + if (autoUnlocking || token || stopped) return; + const t = pageToken(bus); + if (t) { + token = t; + return void showCompose(); + } + autoUnlocking = true; + try { + const res = await moduleApi.fetch('/unlock', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }); + const body = await readJson(res); + if (stopped) return; + if (!res.ok || !body.token) { + gateNote = body.error || `Could not unlock (HTTP ${res.status})`; + return void renderGate(); + } + if (pageToken(bus)) { + token = pageToken(bus); // the other tile got there first + } else { + bus.unlock = { token: String(body.token), expiresAt: Number(body.expiresAt) || Date.now() + 3600000, reason: '' }; + emit('unlock', bus.unlock); + token = bus.unlock.token; + } + gateNote = ''; + showCompose(); + } catch { + if (!stopped) { + gateNote = 'ProdDash unreachable — retrying'; + renderGate(); + } + } finally { + autoUnlocking = false; + } + } + + wrap.addEventListener('keydown', (e) => { + if (!gate.hidden && gateKind === 'pad' && pinPad) pinPad.onKey(e); + }); + + /* ── unlocked: compose or the preset grid ── */ + let composeEls = null; + let rowWatch = null; // watches the quick-emoji row's width for the edge fades + function buildCompose() { + compose.textContent = ''; + rowWatch?.disconnect(); + rowWatch = null; + // Send message names the destination in the box's own placeholder and + // locks from the title bar, so a line above the field would only say + // both again. Quick replies has no placeholder — there it is the only + // place the target is named, so it stays. + let line = null; + if (mode !== 'send') { + const head = el('div', 'sm-compose-head'); + line = el('span', 'sm-sending', targetLine()); + head.appendChild(line); + if (state?.pin?.enabled) { + const lockLink = el('button', 'sm-lock-link', 'Lock'); + lockLink.type = 'button'; + lockLink.title = 'Lock now'; + lockLink.addEventListener('click', () => lock('Locked')); + head.appendChild(lockLink); + } + compose.appendChild(head); + } + + const foot = el('div', 'sm-compose-foot'); + const feedbackEl = el('div', 'sm-feedback'); + const countdown = el('span', 'sm-countdown'); + countdown.hidden = true; + let input = null; + let sendBtn = null; + let grid = null; + + let quick = null; + let grip = null; + if (mode === 'send') { + input = el('textarea', 'sm-input'); + input.rows = 1; + input.placeholder = placeholder(); + input.spellcheck = true; + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + sendText(); + } + }); + // The box and, straddling its bottom edge, the grip: drag it up to give + // the quick emojis more rows (the box gives up the height), down to + // take them back. It snaps a row at a time; the choice is saved with + // the tile. The grip takes no room of its own. + const box = el('div', 'sm-box'); + box.appendChild(input); + grip = el('div', 'sm-grip'); + grip.title = 'Drag for more quick emoji rows'; + grip.setAttribute('role', 'separator'); + grip.setAttribute('aria-orientation', 'horizontal'); + grip.setAttribute('aria-label', 'Quick emoji rows'); + grip.tabIndex = 0; + box.appendChild(grip); + compose.appendChild(box); + sendBtn = el('button', 'sm-sendbtn', 'Send'); + sendBtn.type = 'button'; + sendBtn.addEventListener('click', sendText); + // The admin's quick emojis: one press sends one, no confirmation. As a + // single row it scrolls sideways when the tile is narrow (style.css); + // a plain mouse wheel only moves vertically, so that motion is turned + // into the row's scroll. With more rows allotted it wraps instead. + quick = el('div', 'sm-emoji-sendrow'); + quick.addEventListener('wheel', (e) => { + if (quick.classList.contains('is-multi')) return; + if (!e.deltaY || e.deltaX || quick.scrollWidth <= quick.clientWidth) return; + e.preventDefault(); + quick.scrollLeft += e.deltaY; + }, { passive: false }); + // The scrollbar is hidden; the edge with more keys behind it fades instead. + const paintEdges = () => { + const single = !quick.classList.contains('is-multi'); + quick.classList.toggle('has-more', single && quick.scrollWidth - quick.clientWidth - quick.scrollLeft > 1); + quick.classList.toggle('has-less', single && quick.scrollLeft > 1); + }; + quick.addEventListener('scroll', paintEdges, { passive: true }); + rowWatch?.disconnect(); + rowWatch = new ResizeObserver(() => { + applyQuickRows(); // the tile may have shrunk under the rows it had + paintEdges(); + }); + rowWatch.observe(quick); + rowWatch.observe(compose); + if (moduleApi.instanceSettings.showEmojis !== false) { + for (const q of Array.isArray(state?.quickEmojis) ? state.quickEmojis : []) { + // A single emoji fills a square key; a longer token (a Slack + // :code:, a word) is drawn as small text in a wider pill. + const isGlyph = [...String(q)].length <= 2 || /^\p{Extended_Pictographic}/u.test(q) && [...String(q)].length <= 4; + const b = el('button', 'sm-emoji-send' + (isGlyph ? '' : ' is-text')); + b.type = 'button'; + b.title = `Send ${q}`; + b.setAttribute('aria-label', `Send ${q}`); + b.appendChild(el('span', 'sm-emoji-char', q)); + b.addEventListener('click', () => sendEmoji(q, b)); + quick.appendChild(b); + } + } + // emoji keys on the left, taking what room there is; the outcome icon + // sits beside Send, in a slot that is always there so Send never moves + foot.append(quick, countdown, feedbackEl, sendBtn); + } else { + grid = el('div', 'sm-grid'); + compose.appendChild(grid); + foot.classList.add('is-quick'); + foot.append(countdown, feedbackEl); + } + compose.appendChild(foot); + composeEls = { line, feedback: feedbackEl, countdown, input, sendBtn, grid, quick, grip }; + if (grip) { + applyQuickRows(); + setupGrip(grip); + } + } + + /* ── quick emoji rows: how many, and the grip that sets it ── */ + const QUICK_ROWS_MAX = 4; + const KEY_GAP_PX = 4; + + /** Height of one key row plus its gap, from a real key when there is one. */ + function keyPitch() { + const key = composeEls?.quick?.firstElementChild; + const h = key ? key.getBoundingClientRect().height : 0; + return (h || 32) + KEY_GAP_PX; + } + + /** How many rows the keys need at the row's current width — more would be empty. */ + function rowsNeeded() { + const q = composeEls?.quick; + if (!q || !q.childElementCount) return 1; + const width = q.clientWidth || q.getBoundingClientRect().width; + if (!width) return 1; + let rows = 1; + let used = 0; + for (const key of q.children) { + const w = key.getBoundingClientRect().width || 32; + if (used && used + KEY_GAP_PX + w > width + 0.5) { + rows += 1; + used = w; + } else used += (used ? KEY_GAP_PX : 0) + w; + } + return rows; + } + + /** + * The most rows this tile can hold right now: whatever is left of the + * compose area once the box keeps one line and the foot its first row + * (the one the Send button shares) — and no more rows than the keys + * would fill. The grip rides on the box's edge and costs nothing. + */ + function maxQuickRows() { + const c = composeEls; + if (!c?.quick || !c.input || !c.grip) return 1; + const cs = getComputedStyle(compose); + const inner = compose.clientHeight - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom); + const gaps = parseFloat(cs.rowGap || cs.gap) || 0; // one gap: box → foot + // the box's one-line minimum, as an outer height whatever its box-sizing + const ics = getComputedStyle(c.input); + let inputMin = parseFloat(ics.minHeight) || 34; + if (ics.boxSizing !== 'border-box') { + inputMin += parseFloat(ics.paddingTop) + parseFloat(ics.paddingBottom) + parseFloat(ics.borderTopWidth) + parseFloat(ics.borderBottomWidth); + } + const pitch = keyPitch(); + const spare = inner - gaps - inputMin - (pitch - KEY_GAP_PX); + return Math.max(1, Math.min(QUICK_ROWS_MAX, rowsNeeded(), 1 + Math.floor(spare / pitch))); + } + + /** Lay the keys out in `wanted` rows (default: the tile's saved choice), capped by what fits. Returns the rows in use. */ + function applyQuickRows(wanted = null) { + const c = composeEls; + if (!c?.quick) return 1; + const saved = Math.trunc(Number(moduleApi.instanceSettings.quickRows)) || 1; + const max = maxQuickRows(); + const rows = Math.max(1, Math.min(max, wanted ?? saved)); + c.quick.style.setProperty('--sm-quick-rows', String(rows)); + c.quick.classList.toggle('is-multi', rows > 1); + if (c.grip) { + c.grip.setAttribute('aria-valuenow', String(rows)); + // nothing to trade when every key already fits on the one row + c.grip.hidden = !c.quick.childElementCount || (max <= 1 && rows <= 1 && rowsNeeded() <= 1); + } + return rows; + } + + function currentQuickRows() { + return Math.max(1, Math.trunc(Number(composeEls?.quick?.style.getPropertyValue('--sm-quick-rows'))) || 1); + } + + /** Drag (or arrow keys) on the grip: a row per key pitch, saved on release. */ + function setupGrip(grip) { + grip.addEventListener('pointerdown', (e) => { + if (e.button !== 0) return; + e.preventDefault(); + grip.setPointerCapture(e.pointerId); + grip.classList.add('is-dragging'); + const startY = e.clientY; + const startRows = currentQuickRows(); + const pitch = keyPitch(); + let rows = startRows; + const move = (ev) => { + // up means more rows for the keys, down gives the box its height back + rows = applyQuickRows(startRows + Math.round((startY - ev.clientY) / pitch)); + }; + const done = () => { + grip.removeEventListener('pointermove', move); + grip.classList.remove('is-dragging'); + if (rows !== (Math.trunc(Number(moduleApi.instanceSettings.quickRows)) || 1)) moduleApi.saveInstanceSettings({ quickRows: rows }); + }; + grip.addEventListener('pointermove', move); + grip.addEventListener('pointerup', done, { once: true }); + grip.addEventListener('pointercancel', done, { once: true }); + }); + grip.addEventListener('keydown', (e) => { + if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return; + e.preventDefault(); + const rows = applyQuickRows(currentQuickRows() + (e.key === 'ArrowUp' ? 1 : -1)); + moduleApi.saveInstanceSettings({ quickRows: rows }); + }); + } + + async function showCompose() { + if (stopped) return; + buildCompose(); + gate.hidden = true; + compose.hidden = false; + applyQuickRows(); // measurable only now that the compose area is shown + lockBtn.style.display = state?.pin?.enabled ? '' : 'none'; + reportStatus(); + if (mode === 'quick') await loadPresets(); + else composeEls.input?.focus({ preventScroll: true }); + tickLock(); + } + + /** + * The outcome of a send, as an icon beside the Send button: a check when + * it landed, a blocked sign when it did not, either gone after five + * seconds (or at the next attempt). The sentence behind it rides along as + * the tooltip — the foot is too small for words, the hover is not. + */ + const FEEDBACK_MS = 5000; + function feedback(kind, text = '') { + const f = composeEls?.feedback; + if (!f) return; + clearTimeout(feedback.timer); + f.textContent = ''; + f.className = 'sm-feedback'; + f.removeAttribute('title'); + f.removeAttribute('aria-label'); + f.removeAttribute('role'); + if (!kind) return; + f.innerHTML = kind === 'ok' ? CHECK_SVG : BLOCKED_SVG; // our own SVG constants only + f.classList.add(kind === 'ok' ? 'is-ok' : 'is-error'); + f.setAttribute('role', 'img'); + if (text) { + f.title = text; + f.setAttribute('aria-label', text); + } + feedback.timer = setTimeout(() => { + if (composeEls?.feedback === f) feedback(''); + }, FEEDBACK_MS); + } + const flash = (text) => feedback('ok', text); + const showError = (text) => feedback('error', text); + + function touch(expiresAt) { + if (token && Number(expiresAt) > 0 && bus.unlock.token === token) bus.unlock.expiresAt = Number(expiresAt); + } + + /** POST one message to the selected destination; true when it landed. */ + async function postText(text, what) { + const { provider, destination } = resolveTarget(); + if (!provider || !destination) { + showError(targetLine()); + return false; + } + try { + const res = await moduleApi.fetch('/send', { + method: 'POST', + headers: authHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ text, provider: provider.id, destination: destination.id }), + }); + const body = await readJson(res); + if (res.status === 401) { + lock(body.error || 'Locked — enter the PIN', true); + return false; + } + if (!res.ok) { + showError(body.error || `Send failed (HTTP ${res.status})`); + return false; + } + touch(body.expiresAt); + const where = (body.role || provider.destinationRole) === 'identity' + ? `to ${body.providerLabel || provider.label} as ${body.label || destination.label}` + : `to ${body.label || destination.label}`; + flash(`Sent ${what || ''}${where}`); + return true; + } catch { + showError('ProdDash unreachable — not sent'); + return false; + } + } + + async function sendText() { + if (busy || !composeEls?.input) return; + const text = composeEls.input.value.trim(); + if (!text) return; + busy = true; + composeEls.sendBtn.disabled = true; + try { + if (await postText(text)) composeEls.input.value = ''; + } finally { + busy = false; + if (composeEls?.sendBtn) { + composeEls.sendBtn.disabled = false; + composeEls.input?.focus({ preventScroll: true }); + } + } + } + + /** A quick emoji, as a message of its own. */ + async function sendEmoji(q, btn) { + if (busy) return; + busy = true; + btn.disabled = true; + btn.classList.add('is-busy'); + try { + if (await postText(q, `${q} `)) { + btn.classList.add('is-sent'); + setTimeout(() => btn.classList.remove('is-sent'), 900); + } + } finally { + busy = false; + btn.disabled = false; + btn.classList.remove('is-busy'); + composeEls?.input?.focus({ preventScroll: true }); + } + } + + async function loadPresets() { + if (!composeEls?.grid) return; + composeEls.grid.textContent = ''; + composeEls.grid.appendChild(el('div', 'sm-note', 'Loading…')); + try { + const res = await moduleApi.fetch('/quick', { headers: authHeaders() }); + const body = await readJson(res); + if (res.status === 401) return void lock(body.error || 'Locked — enter the PIN', true); + if (!res.ok) throw new Error(body.error || `HTTP ${res.status}`); + presets = Array.isArray(body.presets) ? body.presets : []; + touch(body.expiresAt); + renderPresets(); + } catch (err) { + if (!composeEls?.grid) return; + composeEls.grid.textContent = ''; + composeEls.grid.appendChild(el('div', 'sm-note is-error', `Could not load quick replies — ${err.message}`)); + } + } + + function renderPresets() { + if (!composeEls?.grid) return; + const grid = composeEls.grid; + grid.textContent = ''; + if (!presets.length) { + grid.appendChild(el('div', 'sm-note', 'No quick replies yet — add some in Admin → Send Message → Presets')); + return; + } + for (const p of presets) { + const b = el('button', 'sm-quick'); + b.type = 'button'; + b.appendChild(el('span', 'sm-quick-label', p.label)); + // A preset bound to its own destination says so — it ignores the selection. + if (p.target) b.appendChild(el('span', 'sm-quick-chan', p.target)); + const check = el('span', 'sm-quick-check'); + check.innerHTML = CHECK_SVG; // our own SVG constant + b.appendChild(check); + b.addEventListener('click', () => sendQuick(p.index, b)); + grid.appendChild(b); + } + } + + async function sendQuick(index, btn) { + if (busy) return; + busy = true; + btn.disabled = true; + btn.classList.add('is-busy'); + try { + const { provider, destination } = resolveTarget(); + const res = await moduleApi.fetch('/quick', { + method: 'POST', + headers: authHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ index, provider: provider?.id || '', destination: destination?.id || '' }), + }); + const body = await readJson(res); + if (res.status === 401) return void lock(body.error || 'Locked — enter the PIN', true); + if (!res.ok) return void showError(body.error || `Send failed (HTTP ${res.status})`); + touch(body.expiresAt); + btn.classList.remove('is-busy'); + btn.classList.add('is-sent'); + flash(body.role === 'identity' + ? `Sent to ${body.providerLabel || provider?.label || ''} as ${body.label || destination?.label || ''}` + : `Sent to ${body.label || destination?.label || ''}`); + setTimeout(() => { + if (btn.isConnected) btn.classList.remove('is-sent'); + }, 1000); + } catch { + showError('ProdDash unreachable — not sent'); + } finally { + busy = false; + btn.disabled = false; + btn.classList.remove('is-busy'); + } + } + + /* ── locking ── */ + function lock(note, fromServer = false) { + const had = token; + token = ''; + presets = null; + composeEls = null; + rowWatch?.disconnect(); + rowWatch = null; + compose.textContent = ''; + if (had && !fromServer) { + moduleApi.fetch('/lock', { method: 'POST', headers: { Authorization: `Bearer ${had}` } }).catch(() => {}); + } + if (had && bus.unlock.token === had) { + // every messaging tile on the page locks with this one + bus.unlock = { token: '', expiresAt: 0, reason: note || 'Locked' }; + emit('unlock', bus.unlock); + } + gateNote = note || ''; + gateError = ''; + gateKind = ''; // rebuild the pad fresh + pinPad = null; + renderGate(); + reportStatus(); + } + + function tickLock() { + if (!token) return; + const left = bus.unlock.expiresAt - Date.now(); + if (left <= 0) return void lock('Locked after inactivity'); + if (composeEls) { + const cd = composeEls.countdown; + if (left < 60000 && state?.pin?.enabled) { + const s = Math.ceil(left / 1000); + cd.textContent = `Locks in 0:${String(s).padStart(2, '0')}`; + cd.hidden = false; + } else cd.hidden = true; + } + } + + /* ── server state ── */ + function statusState() { + const { provider } = resolveTarget(); + const own = state?.status?.state; + if (own === 'error' && !providers().length) return 'error'; + const st = provider?.status?.state; + if (st === 'ok' || st === 'connecting' || st === 'error') return st; + return own === 'ok' || own === 'connecting' || own === 'error' ? own : 'connecting'; + } + + function statusMessage() { + const { provider } = resolveTarget(); + if (provider && provider.status?.state !== 'ok') return provider.status?.message || `${provider.label}: connecting…`; + if (state?.status?.state === 'error' && !providers().length) return state.status.message; + return targetLine(); + } + + function reportStatus() { + if (stopped) return; + const lockWord = !state?.pin?.enabled ? 'PIN off' : token ? 'unlocked' : 'locked'; + moduleApi.setStatus(statusState(), `${statusMessage()} · ${lockWord}`); + } + + function applyState(s) { + if (stopped || !s || typeof s !== 'object') return; + state = s; + bus.quickEmojis = Array.isArray(s.quickEmojis) ? s.quickEmojis.slice() : []; + placeSendAsMenu(); + publishTarget(); + reportStatus(); + if (token) { + // The PIN switched on without one set while unlocked: the server will + // refuse the next send; say so now instead of on failure. + if (s.pin?.enabled && !s.pin?.set) return void lock(''); + if (!composeEls) return void showCompose(); // the page was unlocked before this tile appeared + if (composeEls.line) composeEls.line.textContent = targetLine(); + if (composeEls.input) composeEls.input.placeholder = placeholder(); + lockBtn.style.display = s.pin?.enabled ? '' : 'none'; + } else renderGate(); + } + + async function loadState() { + try { + const res = await moduleApi.fetch('/state'); + if (!res.ok) throw new Error(String(res.status)); + applyState(await res.json()); + } catch { /* the stream paints */ } + } + + /** + * The stream (re)opened: the module may have restarted (an admin Apply, + * a ProdDash restart), which forgets every unlock. Ask rather than find + * out on the next send — and with the PIN off, take a fresh token at once. + */ + async function checkToken() { + if (!token || stopped) return; + try { + const res = await moduleApi.fetch('/verify', { headers: authHeaders() }); + if (stopped || res.ok) return; + if (res.status === 401) lock('Locked — ProdDash restarted', true); + } catch { /* unreachable — the error handler says so */ } + } + + function connect() { + stream = moduleApi.sse('/stream', { + open() { + loadState(); + checkToken(); + }, + error() { + moduleApi.setStatus('error', 'ProdDash Send Message module unreachable — reconnecting…'); + }, + events: { + state(e) { + try { + applyState(JSON.parse(e.data)); + } catch { /* skip a bad frame */ } + }, + }, + }); + } + + return { + start() { + moduleApi.setStatus('connecting', 'Connecting…'); + token = pageToken(bus); + bus.senders.add(moduleApi.instanceId); + emit('senders', {}); + // The other send tile is already up: its choice is the page's. + if (bus.target.provider) { + activeProvider = bus.target.provider; + if (bus.target.destination) chosen = { ...chosen, [bus.target.provider]: bus.target.destination }; + persistChoice(); + } + renderGate(); + connect(); + ticker = setInterval(tickLock, 1000); + }, + stop() { + stopped = true; + clearInterval(ticker); + clearTimeout(feedback.timer); + clearTimeout(targetTimer); + offSelect(); + offTranscripts(); + offUnlock(); + rowWatch?.disconnect(); + stream?.close(); + bus.senders.delete(moduleApi.instanceId); + emit('senders', {}); + if (!bus.senders.size && bus.target.provider) { + // No send tile left: nothing is a target any more. + bus.target = { provider: '', destination: '', providerLabel: '', destinationLabel: '', identity: '' }; + emit('target', bus.target); + } + root.innerHTML = ''; // the page's unlock outlives a remount; a reload asks again + }, + }; +} diff --git a/modules/send-message/module.json b/modules/send-message/module.json new file mode 100644 index 0000000..ab5a8ed --- /dev/null +++ b/modules/send-message/module.json @@ -0,0 +1,63 @@ +{ + "id": "send-message", + "name": "Send Message", + "version": "1.0.0", + "proddash": ">=1.4.0", + "description": "PIN-protected sending to Slack, ProdCom and any other messaging module, from one tile", + "client": "client.js", + "server": "server.js", + "style": "style.css", + "minSize": { + "w": 2, + "h": 2 + }, + "defaultSize": { + "w": 3, + "h": 3 + }, + "configGroups": { + "PIN": { + "toggle": "pinEnabled", + "help": "Nothing leaves this machine until the PIN is entered on a Send message or Quick replies tile. One PIN unlocks every messaging tile on that dashboard page — Send message, Quick replies and Slack's reaction menu — until Auto-lock minutes pass without a send; a reload asks again. With the PIN switched off, the tiles send without asking. Sending stays refused while the PIN is on but not yet set." + }, + "Presets": { + "help": "Quick replies are one per line, Label | Message. Add | platform:destination to bind one — slack:#production or prodcom:FOH — and that reply always goes there, whichever transcript is selected. Quick emojis are sent as a message of their own with one press from the Send message tile." + } + }, + "configSchema": { + "pinEnabled": { + "type": "switch", + "label": "PIN", + "default": true, + "group": "PIN" + }, + "pin": { + "type": "password", + "label": "PIN", + "default": "", + "group": "PIN", + "help": "4–8 digits; the pad unlocks by itself once they are all in." + }, + "pinIdleMinutes": { + "type": "number", + "label": "Auto-lock (min)", + "default": 10, + "group": "PIN" + }, + "quickReplies": { + "type": "text", + "label": "Quick replies", + "default": "", + "group": "Presets", + "help": "One per line: Label | Message. Add | slack:#channel or | prodcom:Channel to bind one to a destination." + }, + "quickEmojis": { + "type": "string", + "label": "Quick emojis", + "default": "👍 🙏 ✅ 👀 🎉", + "group": "Presets", + "help": "Emoji separated by spaces, eight at most. Sent as plain text to whatever is selected." + } + }, + "instanceSchema": {} +} diff --git a/modules/send-message/server.js b/modules/send-message/server.js new file mode 100644 index 0000000..8fe8885 --- /dev/null +++ b/modules/send-message/server.js @@ -0,0 +1,806 @@ +'use strict'; + +/** + * Send Message — server part. + * + * One place a dashboard sends from, whatever the platform. The module owns + * nothing upstream: it finds every module whose manifest says + * "provides": ["messaging"] (Slack, ProdCom, whatever comes next) through + * the shell's /api/modules over loopback, reads each one's destinations + * from its GET /messaging, and hands a message to the provider's + * POST /messaging/send — so Slack keeps its one Slack connection, ProdCom + * its one ProdCom connection, and this module never opens a second one. + * + * It also owns the PIN. A browser unlocks here and gets a token; every + * gated action anywhere — a send through this module, a reaction in a + * Slack transcript — carries that token, and the provider checks it with + * GET /verify over loopback before touching its upstream. No provider has + * a PIN of its own, and no browser on the network can reach a provider's + * send route without having entered the PIN here. + * + * GET /state providers (destinations, identity, status), pin flags, quick emojis + * GET /stream SSE `state` events (change-detected, heartbeated) + * POST /unlock { pin } → { token, expiresAt } (constant-time, lockout) + * with the PIN switched off: {} → a token, no questions + * POST /lock Bearer → forget that token + * GET /verify Bearer → { ok, expiresAt }, 401 otherwise — for providers + * GET /quick Bearer → preset labels, indexes and bindings (never the text) + * POST /send Bearer, { provider, destination, text } + * POST /quick Bearer, { index, provider, destination } + * + * The provider contract is in docs/MODULE-GUIDE.md ("Offering messaging"). + */ + +const crypto = require('crypto'); + +const SELF_ID = 'send-message'; +const CAPABILITY = 'messaging'; + +/** SSE comment ping cadence — keeps idle connections alive through sleepy Wi-Fi. */ +const HEARTBEAT_MS = 15000; +/** Re-read /api/modules: a newly installed provider shows up without a restart. */ +const DISCOVER_MS = 30000; +const DISCOVER_RETRY_MS = 5000; // …sooner while the shell isn't answering yet (boot) +const DISCOVER_FIRST_MS = 1500; // init() runs before the shell listens +/** Each provider's /messaging, re-read this often while a tile is watching… */ +const DESCRIBE_MS = 4000; +/** …and this slowly with nobody looking. */ +const DESCRIBE_IDLE_MS = 30000; +/** A tile counts as watching for this long after its last /state or /stream. */ +const IDLE_AFTER_MS = 30000; +const LOOPBACK_TIMEOUT_MS = 5000; +/** A provider's send may itself wait on its upstream. */ +const SEND_TIMEOUT_MS = 15000; +const PIN_RE = /^\d{4,8}$/; +const PIN_MAX_WRONG = 5; +const PIN_LOCKOUT_MS = 30000; +/** With the PIN off, a token is minted on request and simply lasts. */ +const NO_PIN_TOKEN_MS = 7 * 24 * 60 * 60 * 1000; +const SEND_MIN_INTERVAL_MS = 1000; +const DEFAULT_MAX_CHARS = 4000; +const BODY_LIMIT_BYTES = 64 * 1024; +const UNLOCK_SWEEP_MS = 30000; + +/** Set by init(), read by routes() — rebuilt together on remount. */ +let current = null; + +/* ── 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; +} + +function errorText(err) { + return String(err?.cause?.code || err?.code || err?.message || err || 'error'); +} + +/** + * "Label | Message | platform:destination" per line. A line with no "|" is + * both its label and its message. The binding (optional) names a provider + * — its module id, a prefix of it ("prodcom"), or its label ("Slack") — and + * one of its destinations by id or label ("#production", "FOH"); a reply + * that carries one always goes there, whatever is selected. + */ +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 binding = parts.length > 2 ? parts[2] : ''; + if (!label || !message) continue; + out.push({ label, message, binding }); + } + return out; +} + +/** "👍 🙏 ✅" → ['👍', '🙏', '✅'] — plain text tokens, eight at most. */ +function parseQuickEmojis(text) { + return String(text || '').split(/\s+/).filter(Boolean).slice(0, 8); +} + +/** A provider's GET /messaging answer, as the tiles and the send route use it. */ +function normalizeDescriptor(id, fallbackLabel, body) { + const b = body && typeof body === 'object' ? body : {}; + const st = b.status && typeof b.status === 'object' ? b.status : {}; + const state = st.state === 'ok' || st.state === 'error' ? st.state : 'connecting'; + const destinations = []; + const seen = new Set(); + for (const d of Array.isArray(b.destinations) ? b.destinations : []) { + if (!d || typeof d !== 'object') continue; + const did = String(d.id ?? '').trim(); + if (!did || seen.has(did)) continue; + seen.add(did); + destinations.push({ id: did, label: String(d.label || did), color: typeof d.color === 'string' ? d.color : '' }); + } + const maxChars = Math.trunc(Number(b.maxChars)); + return { + id, + label: String(b.label || fallbackLabel || id), + identity: String(b.identity || ''), + // recipient: a message goes *to* a destination (a Slack channel); + // identity: it goes out *as* one (a ProdCom channel) — the Send as menu + // lists only the latter, and the wording follows + destinationRole: b.destinationRole === 'identity' ? 'identity' : 'recipient', + status: { state, message: String(st.message || '') }, + destinations, + // what to say when the list is empty, if the provider knows why + emptyMessage: String(b.emptyMessage || ''), + maxChars: maxChars > 0 ? Math.min(DEFAULT_MAX_CHARS * 10, maxChars) : DEFAULT_MAX_CHARS, + }; +} + +/** "Slack → #ops" / "ProdCom as FOH" — how a provider + destination pair reads. */ +function pairLabel(descriptor, destination) { + return descriptor.destinationRole === 'identity' + ? `${descriptor.label} as ${destination.label}` + : `${descriptor.label} → ${destination.label}`; +} + +/* ── the module ─────────────────────────────────────────────────────── */ + +module.exports = { + init({ config, log, shell }) { + /* ── config ── */ + const shellPort = Number(shell && shell.port) || 0; + const pinEnabled = config.pinEnabled !== false; + 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; + const providers = new Map(); // module id → { id, label, descriptor | null, error, at } + let listError = ''; // the last /api/modules failure ('' while fine) + let listedAt = 0; + let discoverTimer = null; + let describeTimer = null; + let describing = null; // one descriptor sweep in flight + const streams = new Set(); + const unlocks = new Map(); // token → { expiresAt, lastSendAt, ip } + const pinAttempts = new Map(); // ip → { count, lockedUntil } + let lastInterest = Date.now(); + let lastSig = ''; + + const loopback = (path) => `http://127.0.0.1:${shellPort}${path}`; + + function watched() { + return streams.size > 0 || Date.now() - lastInterest < IDLE_AFTER_MS; + } + + /* ── discovery: who offers messaging ── */ + async function discover() { + discoverTimer = null; + if (stopped) return; + try { + if (!shellPort) throw new Error('shell port unknown (needs ProdDash 1.4.0)'); + const res = await fetch(loopback('/api/modules'), { + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(LOOPBACK_TIMEOUT_MS), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const body = await res.json(); + if (stopped) return; + const mods = (Array.isArray(body?.modules) ? body.modules : []) + .filter((m) => m && typeof m.id === 'string' && m.id !== SELF_ID && Array.isArray(m.provides) && m.provides.includes(CAPABILITY)); + const keep = new Set(); + for (const m of mods) { + keep.add(m.id); + const have = providers.get(m.id); + if (have) have.label = String(m.name || m.id); + else { + providers.set(m.id, { id: m.id, label: String(m.name || m.id), descriptor: null, error: '', at: 0 }); + log(`module "${m.id}" offers messaging`); + } + } + for (const id of [...providers.keys()]) { + if (keep.has(id)) continue; + providers.delete(id); + log(`module "${id}" no longer offers messaging`); + } + listError = ''; + listedAt = Date.now(); + await describeAll(); + } catch (err) { + listError = errorText(err); + broadcastIfChanged(); + } + if (!stopped) { + discoverTimer = setTimeout(discover, listError ? DISCOVER_RETRY_MS : DISCOVER_MS); + discoverTimer.unref?.(); + } + } + + /** Read one provider's /messaging; a failure is kept on the entry, not thrown. */ + async function describe(p) { + try { + const res = await fetch(loopback(`/api/modules/${encodeURIComponent(p.id)}/messaging`), { + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(LOOPBACK_TIMEOUT_MS), + }); + if (res.status === 404) { + // Disabled or uninstalled, most likely: let the module list decide + // now rather than at the next 30 s pass, so the tiles drop it at once. + if (!discoverTimer && !stopped) { + discoverTimer = setTimeout(discover, 250); + discoverTimer.unref?.(); + } + throw new Error('not answering (disabled or restarting)'); + } + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const body = await res.json(); + if (stopped) return; + p.descriptor = normalizeDescriptor(p.id, p.label, body); + if (p.error) log(`${p.label}: describing itself again`); + p.error = ''; + p.at = Date.now(); + } catch (err) { + const text = errorText(err); + if (p.error !== text) log(`${p.label}: cannot read its destinations — ${text}`); + p.error = text; + } + } + + function describeAll() { + if (!describing) { + describing = Promise.all([...providers.values()].map(describe)) + .then(() => broadcastIfChanged()) + .finally(() => { describing = null; }); + } + return describing; + } + + function scheduleDescribe(delay = null) { + if (stopped) return; + clearTimeout(describeTimer); + describeTimer = setTimeout(async () => { + describeTimer = null; + await describeAll(); + scheduleDescribe(); + }, delay ?? (watched() ? DESCRIBE_MS : DESCRIBE_IDLE_MS)); + describeTimer.unref?.(); + } + + /** Somebody is looking: catch up at once if the list is stale or empty. */ + function wake() { + lastInterest = Date.now(); + if (stopped) return; + if ((!providers.size || listError) && Date.now() - listedAt > DISCOVER_RETRY_MS && !discoverTimer) { + discoverTimer = setTimeout(discover, 50); + discoverTimer.unref?.(); + } else if (describeTimer) { + // The idle cadence was in force; move to the watched one now. + scheduleDescribe(DESCRIBE_MS); + } + } + + /* ── what the tiles see ── */ + function providerView(p) { + const d = p.descriptor; + if (!d) { + return { + id: p.id, + label: p.label, + identity: '', + destinationRole: 'recipient', + status: { state: p.error ? 'error' : 'connecting', message: p.error ? `${p.label} module ${p.error}` : 'Reading destinations…' }, + destinations: [], + emptyMessage: '', + maxChars: DEFAULT_MAX_CHARS, + error: p.error, + }; + } + return { ...d, error: p.error, status: p.error ? { state: 'error', message: `${p.label} module ${p.error}` } : d.status }; + } + + function health() { + if (!shellPort) return { status: 'error', message: 'This module needs ProdDash 1.4.0 or newer' }; + const pinLine = !pinEnabled ? 'PIN off' : pinOk ? 'PIN set' : 'PIN on but not set — sending refused'; + if (listError) return { status: listedAt ? 'connecting' : 'error', message: `Cannot read the module list (${listError}) · ${pinLine}` }; + if (!listedAt) return { status: 'connecting', message: 'Looking for messaging modules…' }; + const list = [...providers.values()]; + if (!list.length) return { status: 'error', message: `No module offers messaging — install Slack or ProdCom · ${pinLine}` }; + const parts = list.map((p) => { + const v = providerView(p); + if (v.error) return `${v.label}: ${v.error}`; + const n = v.destinations.length; + const who = v.identity ? ` as ${v.identity}` : ''; + // a provider that is up but unhappy says why, so the admin line does too + const why = v.status.state !== 'ok' && v.status.message ? ` — ${v.status.message}` + : !n && v.emptyMessage ? ` — ${v.emptyMessage}` : ''; + const noun = v.destinationRole === 'identity' ? 'identit' + (n === 1 ? 'y' : 'ies') : 'destination' + (n === 1 ? '' : 's'); + return `${v.label}${who} (${n} ${noun})${why}`; + }); + const bad = list.some((p) => p.error || p.descriptor?.status.state === 'error'); + const pending = list.some((p) => !p.descriptor); + return { + status: pinEnabled && !pinOk ? 'error' : bad ? 'error' : pending ? 'connecting' : 'ok', + message: `${parts.join(' · ')} · ${pinLine}`, + }; + } + + function snapshot() { + const h = health(); + return { + providers: [...providers.values()].map(providerView), + pin: { + enabled: pinEnabled, + set: pinOk, + // the pad submits by itself once this many digits are in (never the PIN) + length: pinEnabled && pinOk ? pin.length : 0, + idleMinutes: idleMs / 60000, + }, + quickEmojis, + quickCount: quickReplies.length, + status: { state: h.status, message: h.message }, + }; + } + + function broadcastIfChanged() { + const snap = snapshot(); + const sig = JSON.stringify(snap); + 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 PIN and its 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 token, or null; a hit slides its idle expiry along. */ + function verify(token) { + const u = token ? unlocks.get(token) : null; + if (!u) return null; + const now = Date.now(); + if (u.expiresAt <= now) { + unlocks.delete(token); + return null; + } + if (pinEnabled) u.expiresAt = now + idleMs; + return { token, ...u }; + } + + /** Everything a send needs before anyone is asked; answers the refusal itself. */ + function sendGate(req, res) { + if (pinEnabled && !pinOk) { + sendJson(res, 403, { error: 'Set a PIN in Admin → Send Message, or switch the PIN off', code: 'no_pin' }); + return null; + } + const u = verify(bearer(req)); + if (!u) { + sendJson(res, 401, { error: 'Locked — enter the PIN', code: 'locked' }); + return null; + } + return u; + } + + function unlock(req, res, given) { + const ip = clientIp(req); + const now = Date.now(); + if (!pinEnabled) { + const t = crypto.randomBytes(32).toString('hex'); + const expiresAt = now + NO_PIN_TOKEN_MS; + unlocks.set(t, { expiresAt, lastSendAt: 0, ip }); + return sendJson(res, 200, { token: t, expiresAt, idleMs: NO_PIN_TOKEN_MS, pinEnabled: false }); + } + if (!pinOk) return sendJson(res, 403, { error: 'Set a PIN in Admin → Send Message, or switch the PIN off', code: 'no_pin' }); + 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, pinEnabled: true }); + } + + /* ── destinations and bindings ── */ + function findProvider(ref) { + const s = String(ref ?? '').trim().toLowerCase(); + if (!s) return null; + for (const p of providers.values()) if (p.id.toLowerCase() === s) return p; + for (const p of providers.values()) { + const label = (p.descriptor?.label || p.label).toLowerCase(); + if (label === s || p.id.toLowerCase().startsWith(s) || label.replace(/\s+/g, '') === s.replace(/\s+/g, '')) return p; + } + return null; + } + + function findDestination(p, ref) { + const s = String(ref ?? '').trim(); + if (!p?.descriptor || !s) return null; + const list = p.descriptor.destinations; + const exact = list.find((d) => d.id === s); + if (exact) return exact; + const name = s.replace(/^#/, '').toLowerCase(); + return list.find((d) => d.label.replace(/^#/, '').toLowerCase() === name) || null; + } + + /** A quick reply's "platform:destination" → { provider, destination } or { error }. */ + function resolveBinding(binding) { + const s = String(binding || '').trim(); + if (!s) return null; + const i = s.indexOf(':'); + if (i > 0) { + const p = findProvider(s.slice(0, i)); + if (!p) return { error: `No messaging module called "${s.slice(0, i).trim()}" for this quick reply` }; + if (!p.descriptor) return { error: `${p.label} hasn't listed its destinations yet — try again in a moment` }; + const d = findDestination(p, s.slice(i + 1)); + if (!d) return { error: `${p.descriptor.label} has no destination "${s.slice(i + 1).trim()}" — check the quick reply in Admin → Send Message` }; + return { provider: p, destination: d }; + } + for (const p of providers.values()) { + const d = findDestination(p, s); + if (d) return { provider: p, destination: d }; + } + return { error: `No destination "${s}" on any messaging module — check the quick reply in Admin → Send Message` }; + } + + /** The label a tile shows for a binding — resolved when it can be, the raw text otherwise. */ + function bindingLabel(binding) { + const r = resolveBinding(binding); + if (!r) return ''; + if (r.error) return String(binding).trim(); + return pairLabel(r.provider.descriptor, r.destination); + } + + /** + * The provider and destination a browser asked for. Both must be ones + * the module has listed — a PIN-unlocked browser chooses among what the + * providers offer, never an arbitrary id. + */ + async function chooseTarget(providerRef, destinationRef) { + const p = findProvider(providerRef); + if (!p) { + const s = String(providerRef ?? '').trim(); + return { error: s ? `${s} isn't offering messaging right now — is the module enabled?` : 'Pick where to send first' }; + } + if (!p.descriptor) await describe(p); + if (!p.descriptor) return { error: `${p.label} module ${p.error || 'is not answering'}` }; + let d = findDestination(p, destinationRef); + if (!d && String(destinationRef ?? '').trim()) { + await describe(p); // a channel added a moment ago + d = findDestination(p, destinationRef); + } + if (!d) { + const s = String(destinationRef ?? '').trim(); + return { error: s ? `${p.descriptor.label} has no destination "${s}" — pick another in Send as` : `Pick a ${p.descriptor.label} destination in Send as` }; + } + return { provider: p, destination: d }; + } + + /* ── delivery: hand the message to the provider over loopback ── */ + async function deliver(req, res, unlock, { provider, destination, text, 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' }); + } + const u = unlocks.get(unlock.token); + if (u) u.lastSendAt = now; + const max = provider.descriptor?.maxChars || DEFAULT_MAX_CHARS; + if (text.length > max) return sendJson(res, 400, { error: `Too long for ${provider.descriptor.label} — ${max} characters at most`, code: 'too_long' }); + const label = provider.descriptor.label; + let answer; + try { + const r = await fetch(loopback(`/api/modules/${encodeURIComponent(provider.id)}/messaging/send`), { + method: 'POST', + headers: { + 'Content-Type': 'application/json; charset=utf-8', + Accept: 'application/json', + Authorization: `Bearer ${unlock.token}`, + }, + body: JSON.stringify({ destination: destination.id, text }), + signal: AbortSignal.timeout(SEND_TIMEOUT_MS), + }); + let body = {}; + try { body = await r.json(); } catch { /* a non-JSON refusal */ } + answer = { status: r.status, body: body && typeof body === 'object' ? body : {} }; + } catch (err) { + const message = `${label} module unreachable (${errorText(err)}) — not sent`; + log(`${what} → ${pairLabel(provider.descriptor, destination)} failed: ${message}`); + return sendJson(res, 502, { error: message, code: 'provider_unreachable' }); + } + if (answer.status === 404) { + return sendJson(res, 502, { error: `${label} module isn't answering (disabled or restarting) — not sent`, code: 'provider_gone' }); + } + if (answer.status < 200 || answer.status >= 300 || answer.body.ok === false) { + const message = String(answer.body.error || `${label} answered HTTP ${answer.status}`); + log(`${what} → ${pairLabel(provider.descriptor, destination)} failed: ${message}`); + const status = answer.status === 401 ? 401 : [400, 404, 429].includes(answer.status) ? answer.status : 502; + return sendJson(res, status, { error: message, code: answer.body.code || `http_${answer.status}` }); + } + const preview = text.replace(/\s+/g, ' ').slice(0, 60); + log(`${what} → ${pairLabel(provider.descriptor, destination)}: "${preview}${text.length > 60 ? '…' : ''}"`); + scheduleDescribe(500); // the provider may have something new to say about itself + return sendJson(res, 200, { + ok: true, + provider: provider.id, + providerLabel: label, + destination: destination.id, + label: String(answer.body.label || destination.label), + role: provider.descriptor.destinationRole, + expiresAt: u ? u.expiresAt : unlock.expiresAt, + }); + } + + /* ── 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 (!shellPort) log('the shell did not say which port it serves on — this module needs ProdDash 1.4.0 or newer'); + else { + if (pinEnabled && !pinOk) log('the PIN is on but not set (4–8 digits) — send tiles stay locked until it is, or until the PIN is switched off'); + if (!pinEnabled) log('PIN off — send tiles send without asking'); + discoverTimer = setTimeout(discover, DISCOVER_FIRST_MS); + discoverTimer.unref?.(); + scheduleDescribe(); + } + + current = { + streams, + snapshot, + health, + wake, + verify, + sendGate, + unlock, + chooseTarget, + deliver, + resolveBinding, + bindingLabel, + quickReplies, + lock(req, res) { + const t = bearer(req); + if (t) unlocks.delete(t); + return sendJson(res, 200, { ok: true }); + }, + }; + + return { + stop() { + stopped = true; + clearTimeout(discoverTimer); + clearTimeout(describeTimer); + 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, + }; + }, + + /** The picker: the two send surfaces, one of each per dashboard. */ + tiles() { + return [ + { + id: 'send', + name: 'Send message', + description: 'A message to the transcript clicked last, on any platform', + single: true, + defaultSize: { w: 3, h: 3 }, + minSize: { w: 2, h: 2 }, + settings: { view: 'send' }, + instanceSchema: { + showEmojis: { type: 'switch', label: 'Quick emojis', default: true }, + }, + }, + { + id: 'quick', + name: 'Quick replies', + description: 'Preset buttons, one press each', + single: true, + defaultSize: { w: 3, h: 2 }, + minSize: { w: 2, h: 1 }, + settings: { view: 'quick' }, + instanceSchema: {}, + }, + ]; + }, + + routes() { + const unmounted = (res) => sendJson(res, 503, { error: 'Send Message 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({ providers: [], pin: { enabled: true, set: false, length: 0 }, quickEmojis: [], status: { state: 'connecting', message: 'Send Message module is restarting…' } })}\n\n`); + return void res.end(); + } + res.write(`event: state\ndata: ${JSON.stringify(current.snapshot())}\n\n`); + try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ } + current.streams.add(res); + current.wake(); + req.on('close', () => current && current.streams.delete(res)); + }, + + '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); + }, + + // Providers ask this over loopback before an upstream action: is this unlock good? + 'GET /verify': (req, res) => { + if (!current) return unmounted(res); + const u = current.verify(bearer(req)); + if (!u) return sendJson(res, 401, { ok: false, error: 'Locked — enter the PIN', code: 'locked' }); + sendJson(res, 200, { ok: true, expiresAt: u.expiresAt }); + }, + + // Labels, indexes and bindings 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, target: current.bindingLabel(q.binding) })), + 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' }); + const target = await current.chooseTarget(body.provider, body.destination); + if (target.error) return sendJson(res, 400, { error: target.error, code: 'bad_target' }); + await current.deliver(req, res, unlock, { ...target, text, 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' }); + // A bound reply ignores the selection; an unbound one needs it. + let target = current.resolveBinding(preset.binding); + if (!target) target = await current.chooseTarget(body.provider, body.destination); + if (target.error) return sendJson(res, 400, { error: target.error, code: 'bad_target' }); + await current.deliver(req, res, unlock, { ...target, text: preset.message, what: `quick reply "${preset.label}"` }); + }), + }; + }, +}; diff --git a/modules/send-message/style.css b/modules/send-message/style.css new file mode 100644 index 0000000..d55ce65 --- /dev/null +++ b/modules/send-message/style.css @@ -0,0 +1,527 @@ +/* Send Message module — scoped to its tiles, shell variables only. + The gate (PIN pad), the compose box and the preset grid were the Slack + module's send surfaces until 1.3.0; they live here now, for every platform. */ + +[data-module="send-message"] .sm-wrap { + --sm-size: 14px; + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + container-type: size; + font-size: var(--sm-size); + color: var(--text); +} +[data-module="send-message"] .sm-wrap:focus { outline: none; } +[data-module="send-message"] [hidden] { display: none !important; } + +/* ── the gate ── */ + +[data-module="send-message"] .sm-gate, +[data-module="send-message"] .sm-compose { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + padding: 6px 10px; + overflow: auto; +} + +[data-module="send-message"] .sm-note { + margin: auto; + padding: 10px; + text-align: center; + color: var(--muted); + font-size: 0.9em; + line-height: 1.45; +} +[data-module="send-message"] .sm-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 (.sm-gate has overflow: auto). */ +[data-module="send-message"] .sm-pad { + flex: 1 1 auto; + display: flex; + flex-direction: column; + gap: 4px; + max-width: 340px; + width: 100%; + margin: 0 auto; +} +[data-module="send-message"] .sm-pad.sm-shake { animation: sm-shake 0.4s ease; } +@keyframes sm-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 · Slack · as Apollo Beach → #production" */ +[data-module="send-message"] .sm-pad-head { + flex: none; + text-align: center; + font-size: 0.8em; + color: var(--muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +[data-module="send-message"] .sm-pad-head b { font-weight: 700; color: var(--text); } + +/* The dots ride on a real numeric input: tapping them focuses it, which is + what raises a phone's keyboard; on a desktop the keyboard types into it + directly. The input itself is invisible — the dots are its display. */ +[data-module="send-message"] .sm-field { + flex: none; + position: relative; + height: 24px; + cursor: text; +} +[data-module="send-message"] .sm-dots { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + pointer-events: none; +} +[data-module="send-message"] .sm-dot { + width: 11px; + height: 11px; + border-radius: 50%; + border: 2px solid var(--muted); + transition: background 0.12s, border-color 0.12s; +} +[data-module="send-message"] .sm-dot.on { background: var(--accent); border-color: var(--accent); } +[data-module="send-message"] .sm-pin-input { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + margin: 0; + padding: 0; + border: 0; + border-radius: 8px; + background: transparent; + color: transparent; + caret-color: transparent; + font: inherit; + text-align: center; + outline: none; + -webkit-text-fill-color: transparent; +} +[data-module="send-message"] .sm-pin-input::selection { background: transparent; } +[data-module="send-message"] .sm-pin-input:focus-visible { box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 45%, transparent); } + +[data-module="send-message"] .sm-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="send-message"] .sm-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="send-message"] .sm-key:hover { background: var(--border); } +[data-module="send-message"] .sm-key:active { background: color-mix(in srgb, var(--accent) 30%, var(--panel)); } +[data-module="send-message"] .sm-key svg { width: 1.1em; height: 1.1em; } +[data-module="send-message"] .sm-key-back { color: var(--muted); } +[data-module="send-message"] .sm-key-blank { visibility: hidden; } +[data-module="send-message"] .sm-pad.is-busy .sm-keys { opacity: 0.6; pointer-events: none; } + +[data-module="send-message"] .sm-pad-msg { + flex: none; + min-height: 1.1em; + text-align: center; + font-size: 0.78em; + line-height: 1.1; + color: var(--muted); +} +[data-module="send-message"] .sm-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 — the dots still take a tap and + the keyboard, and a taller tile brings the keys back. .sm-wrap is the size + container the height is measured against. */ +[data-module="send-message"] .sm-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="send-message"] .sm-pad .sm-keys { display: none; } + [data-module="send-message"] .sm-pad .sm-pad-hint { display: block; } + [data-module="send-message"] .sm-pad { justify-content: center; } +} + +/* ── unlocked ── */ + +[data-module="send-message"] .sm-compose { gap: 6px; } + +[data-module="send-message"] .sm-compose-head { + flex: none; + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + font-size: 0.8em; + color: var(--muted); +} +[data-module="send-message"] .sm-sending { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +[data-module="send-message"] .sm-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="send-message"] .sm-lock-link:hover { color: var(--text); background: var(--border); } + +/* The box takes whatever the quick emoji rows leave it, down to one line. */ +[data-module="send-message"] .sm-box { + flex: 1 1 auto; + min-height: 0; + position: relative; + display: flex; + flex-direction: column; +} +[data-module="send-message"] .sm-input { + flex: 1 1 auto; + min-height: calc(1.4em + 16px); + 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="send-message"] .sm-input:focus { outline: none; border-color: var(--accent); } + +/* The grip: a small pill centred on the box's bottom edge, straddling the + border. Drag it up for more emoji rows. It is positioned over the box, so + it adds no height between the box and the keys. */ +[data-module="send-message"] .sm-grip { + position: absolute; + left: 50%; + bottom: -6px; + transform: translateX(-50%); + width: 44px; + height: 12px; + display: flex; + align-items: center; + justify-content: center; + cursor: ns-resize; + touch-action: none; + z-index: 1; + -webkit-tap-highlight-color: transparent; +} +[data-module="send-message"] .sm-grip::before { + content: ""; + width: 26px; + height: 4px; + border-radius: 2px; + background: var(--muted); + box-shadow: 0 0 0 1.5px var(--panel); + transition: background 0.12s, width 0.12s; +} +[data-module="send-message"] .sm-grip:hover::before, +[data-module="send-message"] .sm-grip:focus-visible::before, +[data-module="send-message"] .sm-grip.is-dragging::before { background: var(--accent); width: 34px; } +[data-module="send-message"] .sm-grip:focus { outline: none; } + +/* The smallest Send message tile (two grid rows, a 110px body): the compose + area tightens its padding, gaps and the box's one-line minimum so that two + rows of quick emoji keys still fit under the box. .sm-wrap is the size + container the height is measured against. */ +@container (max-height: 150px) { + [data-module="send-message"] .sm-compose { padding: 3px 10px; gap: 2px; } + [data-module="send-message"] .sm-input { padding: 4px 9px; line-height: 1.25; min-height: calc(1.25em + 10px); } +} + +/* The Send button and the outcome icon sit right under the box, level with + the first row of keys; further rows of keys run on below them. */ +[data-module="send-message"] .sm-compose-foot { + flex: none; + display: flex; + align-items: flex-start; + gap: 8px; + min-height: 1.6em; + min-width: 0; +} +/* the outcome of a send, as an icon beside the Send button: a check (accent + — green on the default theme) or a blocked sign (danger), fading out as + its five seconds run down; the sentence is the tooltip. The slot keeps its + width while empty so the Send button never shifts. */ +[data-module="send-message"] .sm-feedback { + flex: none; + width: 1.45em; + min-height: 2.1em; /* the Send button's height, so the icon centres on it */ + display: flex; + align-items: center; + justify-content: center; +} +[data-module="send-message"] .sm-countdown { align-self: center; } +[data-module="send-message"] .sm-feedback svg { + width: 1.45em; + height: 1.45em; + flex: none; + animation: sm-pop 0.18s ease-out, sm-fade 0.6s ease-in 4.4s forwards; +} +[data-module="send-message"] .sm-feedback.is-ok { color: var(--accent); } +[data-module="send-message"] .sm-feedback.is-error { color: var(--danger); cursor: help; } +/* the Quick replies foot has no Send button: the icon keeps to the right edge */ +[data-module="send-message"] .sm-compose-foot.is-quick { justify-content: flex-end; } +@keyframes sm-pop { + from { transform: scale(0.6); opacity: 0; } + to { transform: none; opacity: 1; } +} +@keyframes sm-fade { + to { opacity: 0; } +} +[data-module="send-message"] .sm-countdown { + flex: none; + font-size: 0.75em; + color: var(--warn); + font-variant-numeric: tabular-nums; +} +[data-module="send-message"] .sm-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="send-message"] .sm-sendbtn:hover { background: color-mix(in srgb, var(--accent) 85%, var(--text)); } +[data-module="send-message"] .sm-sendbtn:disabled { opacity: 0.6; cursor: default; } + +/* quick emojis: one press each sends it as a message of its own. The keys + take whatever room the foot has to the left of the outcome icon and the + Send button. As one row (the default) they scroll sideways when the tile + is narrow (trackpad, touch, or the wheel — client.js turns a vertical + wheel into a horizontal scroll) while Send keeps its place; with more + rows allotted from the grip they wrap, --sm-quick-rows rows tall. One key + is --sm-key square, so a row's pitch is --sm-key plus the 4px gap. */ +[data-module="send-message"] .sm-emoji-sendrow { + --sm-quick-rows: 1; + --sm-key: calc(2.31 * var(--sm-size)); + flex: 1 1 auto; + display: flex; + gap: 4px; + min-width: var(--sm-key); + overflow-x: auto; + overflow-y: hidden; + overscroll-behavior-x: contain; + scrollbar-width: none; +} +[data-module="send-message"] .sm-emoji-sendrow.is-multi { + flex-wrap: wrap; + align-content: flex-start; + height: calc(var(--sm-quick-rows) * var(--sm-key) + (var(--sm-quick-rows) - 1) * 4px); + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; +} +[data-module="send-message"] .sm-emoji-sendrow::-webkit-scrollbar { display: none; } +/* more keys off one edge or the other: that edge fades out (client.js sets the classes) */ +[data-module="send-message"] .sm-emoji-sendrow.has-more { mask-image: linear-gradient(to right, #000 calc(100% - 16px), transparent); } +[data-module="send-message"] .sm-emoji-sendrow.has-less { mask-image: linear-gradient(to right, transparent, #000 16px); } +[data-module="send-message"] .sm-emoji-sendrow.has-less.has-more { mask-image: linear-gradient(to right, transparent, #000 16px, #000 calc(100% - 16px), transparent); } +[data-module="send-message"] .sm-emoji-sendrow:empty { display: none; } +[data-module="send-message"] .sm-emoji-send { + flex: none; + width: var(--sm-key); + height: var(--sm-key); + 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="send-message"] .sm-emoji-send:hover { border-color: var(--accent); background: var(--border); } +[data-module="send-message"] .sm-emoji-send:active { background: color-mix(in srgb, var(--accent) 30%, var(--panel)); } +[data-module="send-message"] .sm-emoji-send.is-busy { opacity: 0.6; } +[data-module="send-message"] .sm-emoji-send.is-sent { background: var(--accent); border-color: transparent; } +[data-module="send-message"] .sm-emoji-char { font-size: 1em; line-height: 1; } +/* a :code: or a word rather than one emoji: a wider pill of small text */ +[data-module="send-message"] .sm-emoji-send.is-text { + width: auto; + max-width: 7em; + padding: 0 7px; +} +[data-module="send-message"] .sm-emoji-send.is-text .sm-emoji-char { + font-size: 0.72em; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* the preset grid: big targets, labels wrap */ +[data-module="send-message"] .sm-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="send-message"] .sm-grid .sm-note { grid-column: 1 / -1; } +[data-module="send-message"] .sm-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="send-message"] .sm-quick:hover { background: color-mix(in srgb, var(--accent) 22%, var(--panel)); } +[data-module="send-message"] .sm-quick:active { background: color-mix(in srgb, var(--accent) 40%, var(--panel)); } +[data-module="send-message"] .sm-quick.is-busy { opacity: 0.7; } +[data-module="send-message"] .sm-quick.is-sent { + background: var(--accent); + border-color: transparent; + color: var(--accent-text); +} +[data-module="send-message"] .sm-quick-label { overflow-wrap: anywhere; } +[data-module="send-message"] .sm-quick-chan { + font-size: 0.72em; + font-weight: 500; + color: var(--muted); +} +/* sent: the words step aside for a check, in the button itself */ +[data-module="send-message"] .sm-quick { position: relative; } +[data-module="send-message"] .sm-quick-check { + position: absolute; + inset: 0; + display: none; + align-items: center; + justify-content: center; +} +[data-module="send-message"] .sm-quick-check svg { width: 1.6em; height: 1.6em; animation: sm-pop 0.18s ease-out; } +[data-module="send-message"] .sm-quick.is-sent .sm-quick-label, +[data-module="send-message"] .sm-quick.is-sent .sm-quick-chan { visibility: hidden; } +[data-module="send-message"] .sm-quick.is-sent .sm-quick-check { display: flex; } + +/* ── the Send as menu ── + The menu container is created and positioned by the shell (.tile-menu); + it hangs off the body under a [data-module] host, so scoping still applies. */ + +[data-module="send-message"] .sm-menu { min-width: 180px; max-height: min(60vh, 420px); overflow-y: auto; } +[data-module="send-message"] .sm-menu-head { + padding: 6px 9px 2px; + font-size: 11px; + color: var(--muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +[data-module="send-message"] .sm-menu-head b { color: var(--text); font-weight: 700; } +[data-module="send-message"] .sm-menu-head.is-error b { color: var(--danger); } +[data-module="send-message"] .sm-menu-head.is-muted b { color: var(--muted); } +[data-module="send-message"] .sm-menu-head + .sm-menu-head, +[data-module="send-message"] .sm-menu-item + .sm-menu-head, +[data-module="send-message"] .sm-menu-note + .sm-menu-head { + margin-top: 4px; + border-top: 1px solid var(--border); + padding-top: 8px; +} +[data-module="send-message"] .sm-menu-note { + padding: 4px 9px 6px; + font-size: 11px; + color: var(--muted); + max-width: 260px; + line-height: 1.35; +} +[data-module="send-message"] .sm-menu-item { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 6px 9px; + background: none; + border: none; + border-radius: 6px; + color: var(--text); + font-size: 12px; + font-family: inherit; + text-align: left; + cursor: pointer; + white-space: nowrap; +} +[data-module="send-message"] .sm-menu-item:hover { background: var(--border); } +[data-module="send-message"] .sm-menu-item .check { + width: 14px; + flex: none; + color: var(--accent); + font-weight: 700; + visibility: hidden; +} +[data-module="send-message"] .sm-menu-item.on .check { visibility: visible; } +[data-module="send-message"] .sm-menu-item.remembered .check { visibility: visible; color: var(--muted); opacity: 0.6; } +[data-module="send-message"] .sm-menu-item .name { font-weight: 600; } +[data-module="send-message"] .sm-menu-item.on .name { color: var(--accent); } diff --git a/modules/slack/client.js b/modules/slack/client.js index d35b041..2ecc801 100644 --- a/modules/slack/client.js +++ b/modules/slack/client.js @@ -1,11 +1,14 @@ /* Slack — client part. - Tiles from one module (moduleApi.variant / the tile's `view` setting pick): - one channel transcript per configured channel, a PIN-gated free-text sender - and a PIN-gated grid of preset replies. The two senders post to the channel - of whichever transcript was clicked last (see the send target below). - Everything lives inside the tile's root; all traffic goes through the - module's server routes (the token and the PIN never reach a browser). + One channel transcript per configured channel (moduleApi.variant / the + tile's `view` setting pick which). Sending is the Send Message module's: + a click anywhere in a transcript makes its channel the one that module's + tiles post to (the tile's bottom edge lights up while it is), and the + reaction menu a right-click opens rides on that module's unlock — see the + page bus below and "Messaging on the page" in docs/MODULE-GUIDE.md. This + module has no PIN, no send tile and no quick replies of its own since + 1.3.0. Everything lives inside the tile's root; all traffic goes through + the module's server routes (the Slack token never reaches a browser). Nothing here uses innerHTML with data — every string from Slack lands in a text node. */ @@ -84,81 +87,51 @@ async function readJson(res) { const NO_CHANNEL = 'No channel configured — pick channels in Admin → Slack → Channels'; -const LOCK_SVG = ''; const JUMP_SVG = ''; -const BACK_SVG = ''; -/* ── the send target ────────────────────────────────────────────────── */ +/* ── the page bus (shared with the Send Message module) ───────────── */ -/* Deliberately module-level. The guide forbids module-level *per-instance* +/* Deliberately page-level. The guide forbids module-level *per-instance* state — two tiles' closures must never leak into each other — and every - piece of per-tile state in this file still lives inside its create(). This - is a different kind of thing: one fact about the whole page, "which - transcript did the operator click last", that Send message, Quick replies - and every transcript tile on this dashboard have to agree on. It is UI - state of this browser, not of the machine (another screen in the booth may - be pointed at another channel), so it stays out of the server and lives - here: an EventTarget plus the chosen channel id, mirrored in localStorage - under proddash:slack:sendTarget so a reload keeps it. */ -const SEND_TARGET_KEY = 'proddash:slack:sendTarget'; + piece of per-tile state in this file still lives inside its create(). + This is a different kind of thing: facts about the whole page that the + Send Message module's tiles and every transcript on the dashboard have to + agree on — where the next message goes, whether the page is unlocked — + and since those tiles belong to another module, the facts live on + `window` (modules don't import each other), with CustomEvents to say + when they change. The contract is in docs/MODULE-GUIDE.md under + "Messaging on the page"; whichever tile mounts first creates the object. */ +const PROVIDER_ID = 'slack'; +const BUS_EVENT = 'proddash:messaging:'; -const sendTarget = (() => { - const bus = new EventTarget(); - const transcripts = new Map(); // instanceId -> channel id, for every transcript tile on the page - let id = ''; - try { - id = String(localStorage.getItem(SEND_TARGET_KEY) || ''); - } catch { /* private mode or blocked storage — the choice lasts until reload */ } - const emit = () => bus.dispatchEvent(new Event('change')); - return { - /** The channel the operator clicked last ('' if never). */ - get() { - return id; - }, - /** A transcript was clicked: remember it and tell every Slack tile. */ - select(channelId) { - const next = String(channelId || ''); - if (next === id) return; - id = next; - try { - localStorage.setItem(SEND_TARGET_KEY, id); - } catch { /* fine — see above */ } - emit(); - }, - /** Transcript tiles register, so the fallback rule can tell a selected channel whose tile is gone. */ - attach(instanceId, channelId) { - if (transcripts.get(instanceId) === channelId) return; - transcripts.set(instanceId, channelId); - emit(); - }, - detach(instanceId) { - if (transcripts.delete(instanceId)) emit(); - }, - hasTranscript(channelId) { - for (const v of transcripts.values()) if (v === channelId) return true; - return false; - }, - /** - * Where a send goes, given the configured channels in admin order: the - * selection while it is still configured and its transcript is on the - * page, else the first configured channel that IS on this page — with one - * transcript up, that one — and only if the page shows none, the first - * configured channel. A send must never quietly go to a channel the - * operator cannot see. - */ - resolve(configured) { - const list = Array.isArray(configured) ? configured : []; - if (!list.length) return ''; - if (id && list.includes(id) && this.hasTranscript(id)) return id; - const onPage = list.find((c) => this.hasTranscript(c)); - return onPage || list[0]; - }, - on(fn) { - bus.addEventListener('change', fn); - return () => bus.removeEventListener('change', fn); - }, - }; -})(); +function messagingBus() { + if (!window.proddashMessaging) { + window.proddashMessaging = { + version: 1, + target: { provider: '', destination: '', providerLabel: '', destinationLabel: '', identity: '' }, + unlock: { token: '', expiresAt: 0, reason: '' }, + quickEmojis: [], + transcripts: new Map(), + senders: new Set(), + }; + } + return window.proddashMessaging; +} + +function emit(name, detail = {}) { + window.dispatchEvent(new CustomEvent(BUS_EVENT + name, { detail })); +} + +function on(name, fn) { + const handler = (e) => fn(e.detail || {}); + window.addEventListener(BUS_EVENT + name, handler); + return () => window.removeEventListener(BUS_EVENT + name, handler); +} + +/** The page's unlock token, '' once it has expired. */ +function pageToken(bus) { + return bus.unlock.token && bus.unlock.expiresAt > Date.now() ? bus.unlock.token : ''; +} /* ── this module's API path, for (moduleApi.fetch covers the rest) ── */ const API_BASE = '/api/modules/slack'; @@ -274,184 +247,6 @@ const emojiStore = (() => { }; })(); -/* ── the unlock: one PIN entry serves every Slack tile on this page ── */ -/* The same kind of thing as sendTarget — a fact about the whole page, not - one tile: the operator has entered the PIN. It holds the server's unlock - token in memory only (a reload asks again) so that unlocking Send message - also unlocks Quick replies and lets a transcript react. */ -const unlockStore = (() => { - const bus = new EventTarget(); - let token = ''; - let expiresAt = 0; - let reason = ''; - return { - get() { - return token && expiresAt > Date.now() ? token : ''; - }, - expiresAt: () => expiresAt, - reason: () => reason, - set(t, exp) { - token = String(t || ''); - expiresAt = Number(exp) || 0; - reason = ''; - bus.dispatchEvent(new Event('change')); - }, - /** A send or reaction slid the server's expiry along. */ - touch(exp) { - if (token && Number(exp) > 0) expiresAt = Number(exp); - }, - clear(why = '') { - if (!token) return; - token = ''; - expiresAt = 0; - reason = why; - bus.dispatchEvent(new Event('change')); - }, - on(fn) { - bus.addEventListener('change', fn); - return () => bus.removeEventListener('change', fn); - }, - }; -})(); - -/** - * The PIN pad: 1–9, 0 and ⌫ under a row of dots, one per digit of the PIN. - * It submits by itself once as many digits as the PIN has are in (the - * server says how many, never which), or at eight. On success the page-wide - * unlock is set and `onUnlocked` runs. - */ -function createPinPad({ moduleApi, heading = 'Enter PIN', subtitle, pinLength, onUnlocked }) { - let pinBuf = ''; - let busy = false; - let error = ''; - let note = ''; - const pad = el('div', 'sl-pad'); - const head = el('div', 'sl-pad-head'); - const sub = el('span', 'sl-pad-sub'); - head.append(el('b', '', heading), sub); - const dots = el('div', 'sl-dots'); - for (let i = 0; i < 8; i += 1) dots.appendChild(el('span', 'sl-dot')); - const keys = el('div', 'sl-keys'); - const key = (label, cls, onClick, html) => { - const b = el('button', 'sl-key' + (cls ? ' ' + cls : '')); - b.type = 'button'; - if (html) b.innerHTML = html; // our own SVG constants only - else b.textContent = label; - b.addEventListener('click', (e) => { - e.preventDefault(); - onClick(); - }); - return b; - }; - for (const d of ['1', '2', '3', '4', '5', '6', '7', '8', '9']) keys.appendChild(key(d, '', () => press(d))); - keys.appendChild(el('span', 'sl-key sl-key-blank')); - keys.appendChild(key('0', '', () => press('0'))); - const back = key('⌫', 'sl-key-back', backspace, BACK_SVG); - back.title = 'Backspace'; - keys.appendChild(back); - const msg = el('div', 'sl-pad-msg'); - // Shown instead of the keys when the tile is too short for them (style.css). - const hint = el('div', 'sl-pad-hint', 'Type the PIN, or make the tile taller for a keypad.'); - pad.append(head, dots, keys, hint, msg); - - const wanted = () => Math.min(8, Math.max(0, Math.trunc(Number(pinLength?.()) || 0))); - - function refresh() { - const text = subtitle ? String(subtitle() || '') : ''; - sub.textContent = text ? ` · ${text}` : ''; - const n = Math.min(8, pinBuf.length); - // One dot per digit of the PIN; while its length is unknown, 4 and one spare. - const slots = wanted() || Math.max(4, n + (n < 8 ? 1 : 0)); - [...dots.children].forEach((d, i) => { - d.classList.toggle('on', i < n); - d.hidden = i >= slots; - }); - pad.classList.toggle('is-busy', busy); - msg.textContent = error || note; - msg.classList.toggle('is-error', Boolean(error)); - } - - function press(d) { - if (busy || pinBuf.length >= 8) return; - pinBuf += d; - error = ''; - refresh(); - const len = wanted(); - if ((len && pinBuf.length >= len) || pinBuf.length === 8) submit(); - } - - function backspace() { - if (busy) return; - pinBuf = pinBuf.slice(0, -1); - error = ''; - refresh(); - } - - function shake(message) { - error = message; - note = ''; - pinBuf = ''; - refresh(); - pad.classList.remove('sl-shake'); - void pad.offsetWidth; // restart the animation - pad.classList.add('sl-shake'); - } - - async function submit() { - if (busy || pinBuf.length < 4) return; - busy = true; - refresh(); - try { - const res = await moduleApi.fetch('/unlock', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ pin: pinBuf }), - }); - const body = await readJson(res); - if (!res.ok || !body.token) { - shake(body.error || `Could not unlock (HTTP ${res.status})`); - return; - } - pinBuf = ''; - error = ''; - note = ''; - unlockStore.set(body.token, Number(body.expiresAt) || Date.now() + 10 * 60000); - onUnlocked?.(body); - } catch { - shake('ProdDash unreachable — try again'); - } finally { - busy = false; - refresh(); - } - } - - /** Keyboard: digits, Backspace, Enter (submit early), Escape (clear). True when handled. */ - function onKey(e) { - if (/^[0-9]$/.test(e.key)) press(e.key); - else if (e.key === 'Backspace') backspace(); - else if (e.key === 'Enter') submit(); - else if (e.key === 'Escape') { - pinBuf = ''; - error = ''; - refresh(); - } else return false; - e.preventDefault(); - return true; - } - - refresh(); - return { - el: pad, - refresh, - onKey, - setNote(text) { - note = String(text || ''); - error = ''; - refresh(); - }, - }; -} - /* ── the reaction menu: right-click a message, pick an emoji ── */ /* One for the page, on document.body so a tile's overflow can't clip it; `data-module="slack"` keeps the module's scoped styles applying to it. */ @@ -484,7 +279,8 @@ const emojiMenu = (() => { /** Add (or remove) :code: on the message; resolves to '' or an error sentence. */ async function perform(code, remove) { const { moduleApi, channel, ts } = ctx; - const token = unlockStore.get(); + const bus = messagingBus(); + const token = pageToken(bus); if (!token) return 'locked'; try { const res = await moduleApi.fetch('/react', { @@ -494,11 +290,15 @@ const emojiMenu = (() => { }); const body = await readJson(res); if (res.status === 401) { - unlockStore.clear(body.error || 'Locked — enter the PIN'); + // The Send Message module says this unlock is over: every messaging tile locks. + if (bus.unlock.token === token) { + bus.unlock = { token: '', expiresAt: 0, reason: body.error || 'Locked — enter the PIN' }; + emit('unlock', bus.unlock); + } return 'locked'; } if (!res.ok) return body.error || `Could not react (HTTP ${res.status})`; - unlockStore.touch(body.expiresAt); + if (bus.unlock.token === token && Number(body.expiresAt) > 0) bus.unlock.expiresAt = Number(body.expiresAt); return ''; } catch { return 'ProdDash unreachable — try again'; @@ -528,33 +328,25 @@ const emojiMenu = (() => { function render(error = '') { if (!menu || !ctx) return; const { x, y } = anchor(); + const bus = messagingBus(); menu.textContent = ''; menu.hidden = false; - if (!unlockStore.get()) { - // Locked: the PIN first. A pending toggle (a click on a reaction pill) - // is carried out the moment the PIN is in. - const pad = createPinPad({ - moduleApi: ctx.moduleApi, - heading: 'Enter PIN to react', - subtitle: () => (ctx?.sendingAs ? `as ${ctx.sendingAs}` : ''), - pinLength: () => ctx?.pinLength || 0, - onUnlocked() { - if (!ctx) return; - if (ctx.pending) { - const { code, remove } = ctx.pending; - ctx.pending = null; - pick(code, remove); - } else render(); - }, - }); - if (unlockStore.reason()) pad.setNote(unlockStore.reason()); - menu.appendChild(pad.el); - menu.onkeydown = (e) => pad.onKey(e); + if (!pageToken(bus)) { + // Locked: the PIN lives in the Send Message module's tiles, not here. + // A pending toggle (a click on a reaction pill) is carried out the + // moment the page unlocks, if this menu is still open. + const head = el('div', 'sl-emoji-head', ctx.sendingAs ? `React as ${ctx.sendingAs}` : 'React'); + const note = el('div', 'sl-emoji-locked'); + note.appendChild(el('b', '', bus.senders.size ? 'Locked' : 'No Send message tile')); + note.appendChild(el('span', '', bus.senders.size + ? ' — enter the PIN in the Send message tile to react.' + : ' — add Send message from + to react as the location.')); + if (bus.unlock.reason) note.appendChild(el('span', 'sl-emoji-reason', bus.unlock.reason)); + menu.append(head, note); menu.focus({ preventScroll: true }); place(x, y); return; } - menu.onkeydown = null; if (ctx.pending) { // Unlocked and asked for one toggle: do it without showing a picker. const { code, remove } = ctx.pending; @@ -564,10 +356,18 @@ const emojiMenu = (() => { return; } const head = el('div', 'sl-emoji-head', ctx.sendingAs ? `React as ${ctx.sendingAs}` : 'React'); + // The Send Message module's quick emojis head the menu — a reaction + // needs the code Slack knows the character by, so ones it has no name + // for are left out (a :custom_code: is already a name). const quick = el('div', 'sl-emoji-quickrow'); - for (const q of Array.isArray(ctx.quick) ? ctx.quick : []) { - const code = q.name || emojiStore.nameOf(q.char); - if (code) quick.appendChild(button(code)); + const seen = new Set(); + for (const q of Array.isArray(bus.quickEmojis) ? bus.quickEmojis : []) { + const raw = String(q || '').trim(); + const code = /^:[^:\s]+(?:::skin-tone-[2-6])?:$/i.test(raw) ? raw.replace(/^:|:$/g, '').toLowerCase() : emojiStore.nameOf(raw); + if (code && !seen.has(code)) { + seen.add(code); + quick.appendChild(button(code)); + } } const search = el('input', 'sl-emoji-search'); search.type = 'search'; @@ -601,13 +401,13 @@ const emojiMenu = (() => { return { /** - * Open at (x, y) for message `ts` in `channel`. `quick` is the admin's - * quick emoji list; `pending` { code, remove } performs that one toggle - * (a click on a reaction pill) instead of showing the picker. + * Open at (x, y) for message `ts` in `channel`. `pending` { code, remove } + * performs that one toggle (a click on a reaction pill) instead of + * showing the picker. */ - open({ moduleApi, x, y, channel, ts, quick, sendingAs, pinLength, pending = null }) { + open({ moduleApi, x, y, channel, ts, sendingAs, pending = null }) { close(); - ctx = { moduleApi, channel, ts, quick, sendingAs, pinLength, pending }; + ctx = { moduleApi, channel, ts, sendingAs, pending }; menu = el('div', 'sl-emoji-menu'); menu.dataset.module = 'slack'; menu.tabIndex = -1; @@ -624,10 +424,12 @@ const emojiMenu = (() => { close(); } }; + const offUnlock = on('unlock', () => { if (menu) render(); }); document.addEventListener('pointerdown', onDown, true); document.addEventListener('keydown', onKey, true); window.addEventListener('resize', close); cleanup = () => { + offUnlock(); document.removeEventListener('pointerdown', onDown, true); document.removeEventListener('keydown', onKey, true); window.removeEventListener('resize', close); @@ -648,7 +450,15 @@ export default function create({ root, moduleApi }) { // The entry's preset `view` is authoritative; a tile added under 1.0.0 has // the plain variant "transcript", a new one "transcript:". const view = String(moduleApi.instanceSettings.view || (variant.startsWith('transcript') ? 'transcript' : variant) || 'transcript'); - if (view === 'send' || view === 'quick') return createSendTile({ root, moduleApi, mode: view }); + // A Send message / Quick replies tile from before 1.3.0: those live in the + // Send Message module now. Its entry is no longer offered, so the shell + // normally never mounts it; if it does, draw nothing (per the guide). + if (view === 'send' || view === 'quick') { + return { + start() { moduleApi.setStatus('error', 'Sending moved to the Send Message module — remove this tile and add Send message from +'); }, + stop() { root.innerHTML = ''; }, + }; + } return createTranscriptTile({ root, moduleApi }); } @@ -666,6 +476,7 @@ function createTranscriptTile({ root, moduleApi }) { let stream = null; let stopped = false; let targetTimer = null; + const bus = messagingBus(); root.innerHTML = `
@@ -726,12 +537,12 @@ function createTranscriptTile({ root, moduleApi }) { streamEl.scrollTop = streamEl.scrollHeight; } - /* the send target: a click anywhere in the body points the senders here */ + /* the send target: a click anywhere in the body points the Send Message tiles here */ wrap.addEventListener('click', () => { if (!channelId || !lastState?.channels?.[channelId]) return; // not configured — can't be a target - sendTarget.select(channelId); + emit('select', { provider: PROVIDER_ID, destination: channelId }); }); - const offTarget = sendTarget.on(() => { + const offTarget = on('target', () => { // Coalesce: a remount detaches and re-attaches within a few ms. clearTimeout(targetTimer); targetTimer = setTimeout(paintTarget, 50); @@ -744,7 +555,17 @@ function createTranscriptTile({ root, moduleApi }) { }); function isTarget() { - return Boolean(channelId) && sendTarget.resolve(lastState?.channelOrder) === channelId; + return Boolean(channelId) && bus.target.provider === PROVIDER_ID && bus.target.destination === channelId; + } + + /** On the page and configured: that is what lets the Send Message tiles default to this channel. */ + function attach(onPage) { + const have = bus.transcripts.get(moduleApi.instanceId); + if (onPage) { + if (have && have.destination === channelId) return; + bus.transcripts.set(moduleApi.instanceId, { provider: PROVIDER_ID, destination: channelId }); + } else if (!bus.transcripts.delete(moduleApi.instanceId)) return; + emit('transcripts', {}); } function paintTarget() { @@ -878,9 +699,7 @@ function createTranscriptTile({ root, moduleApi }) { y, channel: channelId, ts: m.ts, - quick: lastState.quickEmojis || [], sendingAs: lastState.sendingAs || '', - pinLength: Number(lastState.pinLength) || 0, pending, }); } @@ -972,10 +791,7 @@ function createTranscriptTile({ root, moduleApi }) { const order = Array.isArray(s.channelOrder) ? s.channelOrder.map(String) : []; channelId = pinnedChannel || order[0] || ''; const ch = channelId && s.channels && typeof s.channels === 'object' ? s.channels[channelId] : null; - // Registered while its channel is configured: that is what makes it a - // possible send target (see sendTarget.resolve). - if (ch) sendTarget.attach(moduleApi.instanceId, channelId); - else sendTarget.detach(moduleApi.instanceId); + attach(Boolean(ch)); renderMessages(ch && Array.isArray(ch.messages) ? ch.messages : []); wrap.classList.toggle('is-target', isTarget()); reportStatus(s); @@ -1030,7 +846,7 @@ function createTranscriptTile({ root, moduleApi }) { offTarget(); offEmoji(); emojiMenu.close(); - sendTarget.detach(moduleApi.instanceId); + attach(false); stream?.close(); root.innerHTML = ''; // header controls are removed by the shell }, @@ -1039,487 +855,3 @@ function createTranscriptTile({ root, moduleApi }) { }, }; } - -/* ── send + quick replies ───────────────────────────────────────────── */ - -function createSendTile({ root, moduleApi, mode }) { - let token = unlockStore.get(); // the page's unlock (unlockStore) — memory only, gone on reload - let state = null; // latest snapshot: sending flags, identity, channels, status - let stream = null; - let ticker = null; - let stopped = false; - let busy = false; - let gateNote = ''; // one line under the pad ("Locked after inactivity") - let gateError = ''; // the server's refusal ("Wrong PIN", "locked for 30 s") - let gateKind = ''; // what the gate currently shows, to avoid needless rebuilds - let presets = null; - let targetTimer = null; - - root.innerHTML = ` -
-
- -
`; - const wrap = root.querySelector('.sl-wrap'); - const gate = root.querySelector('.sl-gate'); - const compose = root.querySelector('.sl-compose'); - - const lockBtn = moduleApi.header.addButton({ icon: LOCK_SVG, title: 'Lock', onClick: () => lock('Locked') }); - lockBtn.style.display = 'none'; - - function applyInstanceSettings() { - wrap.style.setProperty('--sl-size', textSizePx(moduleApi) + 'px'); - } - applyInstanceSettings(); - - /* ── where a send goes ── */ - function configured() { - return Array.isArray(state?.channelOrder) ? state.channelOrder.map(String) : []; - } - - function targetId() { - return sendTarget.resolve(configured()); - } - - function targetName() { - const id = targetId(); - if (!id) return ''; - const ch = state?.channels?.[id]; - return `#${ch?.name || id}`; - } - - function sendingLine() { - if (state && !configured().length) return NO_CHANNEL; - const who = state?.sendingAs || '…'; - return `Sending as ${who} → ${targetName() || '…'}`; - } - - function placeholder() { - const name = targetName(); - return name ? `Message ${name}…` : 'Message…'; - } - - /** The selection changed somewhere on the page: every line that names the target follows. */ - function repaintTarget() { - if (stopped || !state) return; - if (token) { - if (composeEls) { - if (composeEls.line) composeEls.line.textContent = sendingLine(); - if (composeEls.input) composeEls.input.placeholder = placeholder(); - } - } else renderGate(); - moduleApi.setStatus(statusState(), `${statusMessage()} · ${token ? 'unlocked' : 'locked'}`); - } - const offTarget = sendTarget.on(() => { - clearTimeout(targetTimer); - targetTimer = setTimeout(repaintTarget, 50); - }); - // Another Slack tile (or the reaction menu) unlocked or locked the page. - const offUnlock = unlockStore.on(() => { - if (stopped) return; - const t = unlockStore.get(); - if (t && t !== token) { - token = t; - gateNote = ''; - gateError = ''; - showCompose(); - } else if (!t && token) lock(unlockStore.reason() || 'Locked', true); - }); - - function authHeaders(extra = {}) { - return { Authorization: `Bearer ${token}`, ...extra }; - } - - /* ── the gate: a PIN pad, or the reason there isn't one ── */ - function renderGate() { - compose.hidden = true; - gate.hidden = false; - lockBtn.style.display = 'none'; - let kind; - if (!state) kind = 'connecting'; - else if (!state.sending?.enabled) kind = 'off'; - else if (!state.sending?.pinSet) kind = 'nopin'; - else if (!configured().length) kind = 'nochannel'; - else kind = 'pad'; - if (kind !== gateKind) { - gateKind = kind; - gate.textContent = ''; - if (kind === 'pad') { - pinPad = createPinPad({ - moduleApi, - subtitle: () => sendingLine().replace(/^Sending/, 'sending'), - pinLength: () => Number(state?.pinLength) || 0, - onUnlocked() { - token = unlockStore.get(); - gateNote = ''; - gateError = ''; - showCompose(); - }, - }); - gate.appendChild(pinPad.el); - } else { - const note = el('div', 'sl-note'); - note.textContent = kind === 'connecting' ? 'Connecting to ProdDash…' - : kind === 'off' ? 'Sending is off — turn it on in Admin → Slack → Sending' - : kind === 'nopin' ? 'Set a PIN in Admin → Slack → Sending' - : NO_CHANNEL; - gate.appendChild(note); - } - } - if (kind === 'pad' && pinPad) { - pinPad.setNote(gateError || gateNote); - pinPad.refresh(); - } - } - - let pinPad = null; - wrap.addEventListener('keydown', (e) => { - if (!gate.hidden && gateKind === 'pad' && pinPad) pinPad.onKey(e); - }); - - /* ── unlocked: compose or the preset grid ── */ - let composeEls = null; - function buildCompose() { - compose.textContent = ''; - // Send message names the channel in the box's own placeholder and locks - // from the title bar, so a line above the field would only say both - // again. Quick replies has no placeholder — there it is the only place - // the target is named, so it stays. - let line = null; - if (mode !== 'send') { - const head = el('div', 'sl-compose-head'); - line = el('span', 'sl-sending', sendingLine()); - const lockLink = el('button', 'sl-lock-link', 'Lock'); - lockLink.type = 'button'; - lockLink.title = 'Lock now'; - lockLink.addEventListener('click', () => lock('Locked')); - head.append(line, lockLink); - compose.appendChild(head); - } - - const foot = el('div', 'sl-compose-foot'); - const msg = el('div', 'sl-msg'); - const countdown = el('span', 'sl-countdown'); - countdown.hidden = true; - let input = null; - let sendBtn = null; - let grid = null; - - if (mode === 'send') { - input = el('textarea', 'sl-input'); - input.rows = 3; - input.placeholder = placeholder(); - input.spellcheck = true; - input.addEventListener('keydown', (e) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - sendText(); - } - }); - compose.appendChild(input); - sendBtn = el('button', 'sl-sendbtn', 'Send'); - sendBtn.type = 'button'; - sendBtn.addEventListener('click', sendText); - // The admin's quick emojis: one press sends one, no confirmation. - const quick = el('div', 'sl-emoji-sendrow'); - for (const q of Array.isArray(state?.quickEmojis) ? state.quickEmojis : []) { - const label = q.char || `:${q.name}:`; - const b = el('button', 'sl-emoji-send'); - b.type = 'button'; - b.title = `Send ${label}`; - b.setAttribute('aria-label', `Send ${label}`); - b.appendChild(q.name && (!q.char || emojiStore.isCustom(q.name)) ? emojiStore.node(q.name) : el('span', 'sl-emoji-char', q.char)); - b.addEventListener('click', () => sendEmoji(q, b)); - quick.appendChild(b); - } - foot.append(msg, countdown, quick, sendBtn); - } else { - grid = el('div', 'sl-grid'); - compose.appendChild(grid); - foot.append(msg, countdown); - } - compose.appendChild(foot); - composeEls = { line, msg, countdown, input, sendBtn, grid }; - } - - async function showCompose() { - buildCompose(); - gate.hidden = true; - compose.hidden = false; - lockBtn.style.display = ''; - moduleApi.setStatus(statusState(), `${statusMessage()} · unlocked`); - if (mode === 'quick') await loadPresets(); - else composeEls.input?.focus(); - tickLock(); - } - - function flash(text) { - if (!composeEls) return; - composeEls.msg.textContent = text; - composeEls.msg.className = 'sl-msg is-flash'; - clearTimeout(flash.timer); - flash.timer = setTimeout(() => { - if (composeEls && composeEls.msg.textContent === text) { - composeEls.msg.textContent = ''; - composeEls.msg.className = 'sl-msg'; - } - }, 1800); - } - - function showError(text) { - if (!composeEls) return; - clearTimeout(flash.timer); - composeEls.msg.textContent = text; - composeEls.msg.className = 'sl-msg is-error'; - } - - /** POST one message to the selected channel; true when it landed. */ - async function postText(text, what) { - const channel = targetId(); - if (!channel) { - showError(NO_CHANNEL); - return false; - } - try { - const res = await moduleApi.fetch('/send', { - method: 'POST', - headers: authHeaders({ 'Content-Type': 'application/json' }), - body: JSON.stringify({ text, channel }), - }); - const body = await readJson(res); - if (res.status === 401) { - lock(body.error || 'Locked — enter the PIN', true); - return false; - } - if (!res.ok) { - showError(body.error || `Send failed (HTTP ${res.status})`); - return false; - } - unlockStore.touch(body.expiresAt); - flash(`Sent ${what || ''}to ${body.channel || targetName()} ✓`); - return true; - } catch { - showError('ProdDash unreachable — not sent'); - return false; - } - } - - async function sendText() { - if (busy || !composeEls?.input) return; - const text = composeEls.input.value.trim(); - if (!text) return; - busy = true; - composeEls.sendBtn.disabled = true; - try { - if (await postText(text)) composeEls.input.value = ''; - } finally { - busy = false; - if (composeEls?.sendBtn) { - composeEls.sendBtn.disabled = false; - composeEls.input?.focus(); - } - } - } - - /** A quick emoji: the character (or :code: for a custom one) as a message of its own. */ - async function sendEmoji(q, btn) { - if (busy) return; - busy = true; - btn.disabled = true; - btn.classList.add('is-busy'); - try { - const text = q.char || `:${q.name}:`; - if (await postText(text, `${text} `)) { - btn.classList.add('is-sent'); - setTimeout(() => btn.classList.remove('is-sent'), 900); - } - } finally { - busy = false; - btn.disabled = false; - btn.classList.remove('is-busy'); - composeEls?.input?.focus(); - } - } - - async function loadPresets() { - if (!composeEls?.grid) return; - composeEls.grid.textContent = ''; - composeEls.grid.appendChild(el('div', 'sl-note', 'Loading…')); - try { - const res = await moduleApi.fetch('/quick', { headers: authHeaders() }); - const body = await readJson(res); - if (res.status === 401) return void lock(body.error || 'Locked — enter the PIN', true); - if (!res.ok) throw new Error(body.error || `HTTP ${res.status}`); - presets = Array.isArray(body.presets) ? body.presets : []; - unlockStore.touch(body.expiresAt); - renderPresets(); - } catch (err) { - composeEls.grid.textContent = ''; - const note = el('div', 'sl-note is-error', `Could not load quick replies — ${err.message}`); - composeEls.grid.appendChild(note); - } - } - - function renderPresets() { - if (!composeEls?.grid) return; - const grid = composeEls.grid; - grid.textContent = ''; - if (!presets.length) { - grid.appendChild(el('div', 'sl-note', 'No quick replies yet — add some in Admin → Slack → Sending')); - return; - } - for (const p of presets) { - const b = el('button', 'sl-quick'); - b.type = 'button'; - b.appendChild(el('span', 'sl-quick-label', p.label)); - // A preset bound to its own channel says so — it ignores the selection. - if (p.channel) b.appendChild(el('span', 'sl-quick-chan', `→ ${p.channel}`)); - b.addEventListener('click', () => sendQuick(p.index, b)); - grid.appendChild(b); - } - } - - async function sendQuick(index, btn) { - if (busy) return; - busy = true; - btn.disabled = true; - btn.classList.add('is-busy'); - try { - const channel = targetId(); - const res = await moduleApi.fetch('/quick', { - method: 'POST', - headers: authHeaders({ 'Content-Type': 'application/json' }), - body: JSON.stringify(channel ? { index, channel } : { index }), - }); - const body = await readJson(res); - if (res.status === 401) return void lock(body.error || 'Locked — enter the PIN', true); - if (!res.ok) return void showError(body.error || `Send failed (HTTP ${res.status})`); - unlockStore.touch(body.expiresAt); - if (composeEls) { - composeEls.msg.textContent = ''; - composeEls.msg.className = 'sl-msg'; - } - btn.classList.remove('is-busy'); - btn.classList.add('is-sent'); - const label = btn.querySelector('.sl-quick-label'); - const original = label.textContent; - label.textContent = 'Sent ✓'; - flash(`Sent to ${body.channel || targetName()} ✓`); - setTimeout(() => { - if (!btn.isConnected) return; - label.textContent = original; - btn.classList.remove('is-sent'); - }, 1000); - } catch { - showError('ProdDash unreachable — not sent'); - } finally { - busy = false; - btn.disabled = false; - btn.classList.remove('is-busy'); - } - } - - /* ── locking ── */ - function lock(note, fromServer = false) { - const had = token; - token = ''; - presets = null; - composeEls = null; - compose.textContent = ''; - if (had && !fromServer) { - moduleApi.fetch('/lock', { method: 'POST', headers: { Authorization: `Bearer ${had}` } }).catch(() => {}); - } - if (had) unlockStore.clear(note || 'Locked'); // every Slack tile on the page locks with this one - gateNote = note || ''; - gateError = ''; - gateKind = ''; // rebuild the pad fresh - pinPad = null; - renderGate(); - moduleApi.setStatus(statusState(), `${statusMessage()} · locked`); - } - - function tickLock() { - if (!token) return; - const left = unlockStore.expiresAt() - Date.now(); - if (left <= 0) return void lock('Locked after inactivity'); - if (composeEls) { - const cd = composeEls.countdown; - if (left < 60000) { - const s = Math.ceil(left / 1000); - cd.textContent = `Locks in 0:${String(s).padStart(2, '0')}`; - cd.hidden = false; - } else cd.hidden = true; - } - } - - /* ── server state ── */ - function statusState() { - const st = state?.status?.state; - return st === 'ok' || st === 'connecting' || st === 'error' ? st : 'connecting'; - } - function statusMessage() { - if (state?.status?.state === 'ok') return sendingLine(); - return state?.status?.message || 'Connecting to Slack…'; - } - - function applyState(s) { - if (stopped || !s || typeof s !== 'object') return; - state = s; - emojiStore.sync(moduleApi, s.emojiVersion); - moduleApi.setStatus(statusState(), `${statusMessage()} · ${token ? 'unlocked' : 'locked'}`); - if (token) { - // Sending switched off or the PIN removed while unlocked: the server - // will refuse the next send; say so now instead of on failure. - if (!s.sending?.enabled || !s.sending?.pinSet) return void lock(''); - if (!composeEls) return void showCompose(); // the page was unlocked before this tile appeared - if (composeEls) { - if (composeEls.line) composeEls.line.textContent = sendingLine(); - if (composeEls.input) composeEls.input.placeholder = placeholder(); - } - } else renderGate(); - } - - async function loadState() { - try { - const res = await moduleApi.fetch('/state'); - if (!res.ok) throw new Error(String(res.status)); - applyState(await res.json()); - } catch { /* the stream paints */ } - } - - function connect() { - stream = moduleApi.sse('/stream', { - open() { loadState(); }, - error() { - moduleApi.setStatus('error', 'ProdDash Slack module unreachable — reconnecting…'); - }, - events: { - state(e) { - try { - applyState(JSON.parse(e.data)); - } catch { /* skip a bad frame */ } - }, - }, - }); - } - - return { - start() { - moduleApi.setStatus('connecting', 'Connecting to Slack…'); - token = unlockStore.get(); - renderGate(); - connect(); - ticker = setInterval(tickLock, 1000); - }, - stop() { - stopped = true; - clearInterval(ticker); - clearTimeout(flash.timer); - clearTimeout(targetTimer); - offTarget(); - offUnlock(); - stream?.close(); - root.innerHTML = ''; // the page's unlock outlives a remount; a reload asks again - }, - }; -} diff --git a/modules/slack/module.json b/modules/slack/module.json index 8785160..5eb7330 100644 --- a/modules/slack/module.json +++ b/modules/slack/module.json @@ -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": { diff --git a/modules/slack/server.js b/modules/slack/server.js index 39d196e..d85f693 100644 --- a/modules/slack/server.js +++ b/modules/slack/server.js @@ -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/, GET /avatar/ 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 — "#" 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 + * — "#" 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) }); }), }; }, diff --git a/modules/slack/style.css b/modules/slack/style.css index 4d6449f..6c32ca4 100644 --- a/modules/slack/style.css +++ b/modules/slack/style.css @@ -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; +} diff --git a/tools/prodcom-mock.js b/tools/prodcom-mock.js index 04df8b8..72b740c 100644 --- a/tools/prodcom-mock.js +++ b/tools/prodcom-mock.js @@ -1,9 +1,15 @@ /** * Mock ProdCom API for verifying the prodcom-transcript module without a * live ProdCom. Serves the endpoints the module uses — channels, groups, - * transcript history and the SSE stream — and simulates live speech: - * entries appear in progress, grow word by word (same id, in-place update), - * then complete. + * transcript history, the SSE stream and POST /api/v1/channels/{id}/send — + * and simulates live speech: entries appear in progress, grow word by word + * (same id, in-place update), then complete. Text sent to a channel lands + * in that channel's history as a completed entry with source "typed" and + * goes out on the stream, which is how a send from the Send Message module + * can be watched arriving in the transcript tile. A text containing "!429" + * answers RATE_LIMITED; an unknown channel NOT_FOUND. With PRODCOM_MOCK_KEY + * set, every request must carry it as a Bearer token (UNAUTHORIZED + * otherwise) — the module's "API key" field. * * node tools/prodcom-mock.js [port] (default 24480) * @@ -17,12 +23,13 @@ const http = require('http'); const PORT = Number.parseInt(process.argv[2] || '24480', 10); const STEP_MS = Number.parseInt(process.env.PRODCOM_STEP_MS || '700', 10); +const API_KEY = String(process.env.PRODCOM_MOCK_KEY || ''); const CHANNELS = [ - { id: 'ch-stage', name: 'Stage', color: '#e0645c' }, - { id: 'ch-foh', name: 'FOH', color: '#5c9de0' }, - { id: 'ch-video', name: 'Video', color: '#5cd08c' }, - { id: 'ch-light', name: 'Lighting', color: '#e0b45c' }, + { id: 'ch-stage', name: 'Stage', color: '#e0645c', sourceType: 'localAudio', typingEnabled: true, isSelected: true, unreadCount: 0 }, + { id: 'ch-foh', name: 'FOH', color: '#5c9de0', sourceType: 'localAudio', typingEnabled: true, isSelected: false, unreadCount: 0 }, + { id: 'ch-video', name: 'Video', color: '#5cd08c', sourceType: 'network', typingEnabled: false, isSelected: false, unreadCount: 0 }, + { id: 'ch-light', name: 'Lighting', color: '#e0b45c', sourceType: 'network', typingEnabled: false, isSelected: false, unreadCount: 0 }, ]; const GROUPS = [ @@ -69,6 +76,7 @@ function stepSimulation() { text: words[0], date: new Date().toISOString(), inProgress: true, + source: 'audio', }; speaking = { entry, words, at: 1 }; history.push(entry); @@ -90,15 +98,68 @@ function stepSimulation() { setInterval(stepSimulation, STEP_MS); -function sendJson(res, body) { - res.writeHead(200, { 'Content-Type': 'application/json' }); +function sendJson(res, body, status = 200) { + res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(body)); } +/** ProdCom's error envelope. */ +function sendError(res, status, code, message) { + sendJson(res, { error: { code, message }, meta: { timestamp: new Date().toISOString() } }, status); +} + +function readBody(req) { + return new Promise((resolve) => { + let data = ''; + req.on('data', (c) => { data += c; }); + req.on('end', () => { + try { resolve(data ? JSON.parse(data) : {}); } catch { resolve(null); } + }); + req.on('error', () => resolve(null)); + }); +} + +/** Typed text onto a channel: a completed entry with source "typed", like ProdCom makes. */ +function sendText(channel, text) { + const entry = { + id: 'e' + RUN + '-' + nextId++, + channelId: channel.id, + channelName: channel.name, + text, + date: new Date().toISOString(), + inProgress: false, + source: 'typed', + hasBeenSeen: false, + }; + history.push(entry); + if (history.length > 200) history.shift(); + broadcast(entry); + console.log(`[prodcom-mock] typed → ${channel.name}: ${text}`); +} + const server = http.createServer((req, res) => { const url = new URL(req.url, `http://x.invalid`); const p = url.pathname; + if (API_KEY && req.headers.authorization !== `Bearer ${API_KEY}`) { + return sendError(res, 401, 'UNAUTHORIZED', 'Authentication required or invalid API key'); + } + + // POST /api/v1/channels/{id or name}/send { text } + const send = /^\/api\/v1\/channels\/([^/]+)\/send$/.exec(p); + if (send && req.method === 'POST') { + const ref = decodeURIComponent(send[1]); + const channel = CHANNELS.find((c) => c.id === ref) || CHANNELS.find((c) => c.name.toLowerCase() === ref.toLowerCase()); + return void readBody(req).then((body) => { + if (!channel) return sendError(res, 404, 'NOT_FOUND', 'Channel not found'); + const text = typeof body?.text === 'string' ? body.text.trim() : ''; + if (!body || !text) return sendError(res, 400, 'VALIDATION_ERROR', 'text is required'); + if (text.includes('!429')) return sendError(res, 429, 'RATE_LIMITED', 'Slow down'); + sendText(channel, text); + sendJson(res, { data: { sent: true }, meta: { timestamp: new Date().toISOString() } }); + }); + } + if (p === '/api/v1/channels') return sendJson(res, { data: CHANNELS }); if (p === '/api/v1/groups') return sendJson(res, { data: GROUPS }); if (p === '/api/v1/transcript') {