Send Message 1.0.0: one PIN, one place to send from; Slack 1.3.0 and ProdCom 1.4.0 become messaging providers

A new send-message module owns everything a dashboard sends: the PIN
(switchable off in admin), the unlock tokens, the Send message and Quick
replies tiles, the quick replies and quick emojis. It opens no upstream
connection of its own — it finds every module declaring
provides: ["messaging"] through the shell's module list and hands each
message to that module's POST /messaging/send over loopback, so Slack keeps
its one Slack connection and ProdCom its one ProdCom connection. Providers
check the browser's unlock token with GET /verify over loopback before
touching their upstream, so no browser on the network can post as the
location without having entered the PIN here.

Slack 1.3.0 is view-only plus reactions: the Sending group, PIN, send tiles
and quick replies are gone; it serves the messaging descriptor (channels as
recipients) and its reactions ride on the Send Message unlock. A layout's
old Send message / Quick replies tiles draw nothing until removed.

ProdCom 1.4.0 serves the descriptor as identities — only channels with
typingEnabled, since typed text goes out *as* a channel — and posts with
POST /api/v1/channels/{id}/send. Its tile is a click-to-target like Slack's
(accent stripe along the bottom), and typed entries carry a keyboard mark.

The tiles: click any Slack or ProdCom transcript to point sends there; a
Send as menu (only for platforms with identities, hidden otherwise) picks
the ProdCom channel to speak as; the choice is saved with the tile so named
layouts remember it. Wording follows the role — "Send message to #ops…" /
"Send message to ProdCom as FOH…". The PIN pad's dots ride on a real
numeric input so a phone's keyboard appears. Outcome is an icon beside
Send (check / blocked, five seconds, sentence as tooltip). A pill on the
box's bottom edge trades box height for rows of quick-emoji keys, a row
at a time; the single row scrolls sideways on a narrow tile with Send
pinned in place.

The module guide documents the provider contract and the page bus
(window.proddashMessaging + proddash:messaging:* events) the tiles share;
the ProdCom mock gained the send route.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Waters Church
2026-10-05 13:55:55 -04:00
co-authored by Claude Fable 5.1
parent 3b81efcb51
commit 1274360ee6
15 changed files with 3409 additions and 1528 deletions
+113 -3
View File
@@ -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": "<id>", "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": "<one sentence a tech can act on>", "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:<name>` 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`.