Slack module: several channels, send tiles follow the transcript clicked last

One Slack tile per channel instead of one channel per machine. Admin picks
any number of the account's channels (`channels`, a multiselect fed by
/channels; a `channel` string saved by 1.0.0 is read as a one-item list, the
module never rewrites admin config). tiles() offers one transcript entry per
channel — id "transcript:<id>", named "#<channel>" so the shell titles the
tile with it, settings { view, channel } — plus Send message and Quick
replies, each `single: true`. A transcript tile from 1.0.0 (no channel in its
settings) shows the first configured channel.

Server: one poller per channel, each with its own window, thread cache,
membership and error, sharing the token, the name caches and the SSE fan-out.
Slack allows an internal app ~50+ conversations.history calls a minute per
token, so the module keeps itself under 40:

  interval per channel = max(pollSeconds, ceil(channels × 60 / 40)) s
  1 → 2 s, 2 → 3 s, 3 → 5 s, 5 → 8 s

with pollers phase-locked across that interval so they never fire in a
burst (first reads staggered 250 ms apart after boot), one auth.test shared
by all of them, and a 429 backing every poller off together. /state returns
`channels` keyed by id (name, isMember, messages, pollAge, error, status)
plus `channelOrder`; /stream stays one `state` snapshot per change. The
health line lists the channels and names any with a problem:
"Apollo Beach · #production, #ops · every 3 s (rate budget) · sending on".
/send and /quick take `channel`, which must be one of the configured ids
(400 otherwise) — a PIN-unlocked browser chooses among the admin's channels,
never an arbitrary one; a quick reply's own "| #channel" wins over it.

Client: a click anywhere in a transcript's body makes its channel the send
target; the chosen transcript carries a 3px accent stripe along the bottom
of the tile body (the chrome is the shell's), the others hint at it on hover.
The selection is one fact about the whole page, so it lives in a deliberate
module-level store in client.js (an EventTarget plus the id, mirrored in
localStorage under proddash:slack:sendTarget so a reload keeps it) — the
comment there says why this is not the per-instance state the guide forbids.
Send message and Quick replies show "Sending as Apollo Beach → #ops" and
follow the selection live; when the selected channel is no longer configured
or its transcript tile is gone, sends fall back to the first configured
channel, and the headers say so. PIN pad, lockout, unlock tokens, Enter to
send, auto-lock, the transcript rendering and the per-tile settings are as
they were.

The mock gains a quiet #ops trickle so two transcripts visibly move at their
own pace; a post to either channel already lands in that channel's history.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Waters Church
2026-09-14 13:28:19 -04:00
co-authored by Claude Fable 5.1
parent 7d34604f2f
commit b9e895d324
5 changed files with 719 additions and 201 deletions
+14 -11
View File
@@ -1,9 +1,9 @@
{
"id": "slack",
"name": "Slack",
"version": "1.0.0",
"version": "1.1.0",
"proddash": ">=1.4.0",
"description": "A Slack channel's live transcript, plus PIN-protected sending as this location",
"description": "Live transcripts of this location's Slack channels, plus PIN-protected sending as the location",
"client": "client.js",
"server": "server.js",
"style": "style.css",
@@ -14,9 +14,12 @@
"collapsed": "whenSet",
"help": "Create a Slack app in the workspace (api.slack.com/apps → Create New App → From scratch) and, under OAuth & Permissions → User Token Scopes, add channels:history, channels:read, groups:history, groups:read, chat:write and users:read (users:read.email is not needed). Sign in to Slack as this location's account — the one named for the campus — open the app's OAuth & Permissions page and press Install to Workspace (or Reinstall to Workspace), then copy the User OAuth Token (xoxp-…) it shows and paste it here: messages from this machine are then literally from the location. A bot token (xoxb-…) works too; it posts as the app, with \"Send as\" naming the location."
},
"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 to the first channel here until one is clicked. 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 leave this machine only from a Send or Quick replies tile, and only after the PIN is entered on that tile — every tile asks again after Auto-lock minutes without a send. Sending stays refused until a 4–8 digit PIN is set here. Quick replies are one per line, Label | Message, with an optional | #channel-name to send that one somewhere other than the transcript channel."
"help": "Messages leave this machine only from a Send or Quick replies tile, and only after the PIN is entered on that tile — every tile asks again after Auto-lock minutes without a send. Sending stays refused until a 4–8 digit PIN is set here. A message goes to the channel of the transcript clicked last. 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."
}
},
"configSchema": {
@@ -34,20 +37,20 @@
"group": "Slack account",
"help": "Only used with a bot token — a user token always sends as its own account; needs the chat:write.customize scope."
},
"channel": {
"type": "select",
"label": "Channel",
"default": "",
"group": "Channel",
"channels": {
"type": "multiselect",
"label": "Channels",
"default": [],
"group": "Channels",
"optionsRoute": "/channels",
"help": "The channel the transcript shows and messages go to by default. Only channels the account has joined are listed — join one in Slack first."
"help": "One transcript tile per channel; the first one is where sends go until a transcript is clicked. Only channels the account has joined are listed — join one in Slack first."
},
"pollSeconds": {
"type": "number",
"label": "Refresh interval (s)",
"default": 2,
"group": "Transcript",
"help": "How often the channel is re-read (1 s at the fastest)."
"help": "How often each channel is re-read (1 s at the fastest). With several channels the interval stretches so the module stays inside Slack's rate limit — at least 1.5 s × the number of channels; the health line shows the interval in use."
},
"historyCount": {
"type": "number",
@@ -85,7 +88,7 @@
"label": "Quick replies",
"default": "",
"group": "Sending",
"help": "One per line: Label | Message. Add | #channel-name to send one somewhere else."
"help": "One per line: Label | Message. Add | #channel-name to bind one to a channel — it then ignores which transcript is selected."
}
},
"instanceGroups": {