Send Message 1.0.0: one PIN, one place to send from; Slack 1.3.0 and ProdCom 1.4.0 become messaging providers
A new send-message module owns everything a dashboard sends: the PIN
(switchable off in admin), the unlock tokens, the Send message and Quick
replies tiles, the quick replies and quick emojis. It opens no upstream
connection of its own — it finds every module declaring
provides: ["messaging"] through the shell's module list and hands each
message to that module's POST /messaging/send over loopback, so Slack keeps
its one Slack connection and ProdCom its one ProdCom connection. Providers
check the browser's unlock token with GET /verify over loopback before
touching their upstream, so no browser on the network can post as the
location without having entered the PIN here.
Slack 1.3.0 is view-only plus reactions: the Sending group, PIN, send tiles
and quick replies are gone; it serves the messaging descriptor (channels as
recipients) and its reactions ride on the Send Message unlock. A layout's
old Send message / Quick replies tiles draw nothing until removed.
ProdCom 1.4.0 serves the descriptor as identities — only channels with
typingEnabled, since typed text goes out *as* a channel — and posts with
POST /api/v1/channels/{id}/send. Its tile is a click-to-target like Slack's
(accent stripe along the bottom), and typed entries carry a keyboard mark.
The tiles: click any Slack or ProdCom transcript to point sends there; a
Send as menu (only for platforms with identities, hidden otherwise) picks
the ProdCom channel to speak as; the choice is saved with the tile so named
layouts remember it. Wording follows the role — "Send message to #ops…" /
"Send message to ProdCom as FOH…". The PIN pad's dots ride on a real
numeric input so a phone's keyboard appears. Outcome is an icon beside
Send (check / blocked, five seconds, sentence as tooltip). A pill on the
box's bottom edge trades box height for rows of quick-emoji keys, a row
at a time; the single row scrolls sideways on a narrow tile with Send
pinned in place.
The module guide documents the provider contract and the page bus
(window.proddashMessaging + proddash:messaging:* events) the tiles share;
the ProdCom mock gained the send route.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
3b81efcb51
commit
1274360ee6
@@ -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 = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M5 12h14"/></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 = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></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 =
|
||||
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">' +
|
||||
'<circle cx="12" cy="8.5" r="3.2"/>' +
|
||||
'<path d="M5.5 19c1.6-3.2 3.9-4.8 6.5-4.8s4.9 1.6 6.5 4.8"/></svg>';
|
||||
// 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'
|
||||
? '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
|
||||
'<rect x="3" y="7" width="18" height="11" rx="2"/><path d="M7 11h1M11 11h1M15 11h1M8 14.5h8"/></svg>'
|
||||
: entry.source === 'automation'
|
||||
? '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
|
||||
'<path d="M13 3 5 14h6l-1 7 8-11h-6z"/></svg>'
|
||||
: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">' +
|
||||
'<circle cx="12" cy="8.5" r="3.2"/>' +
|
||||
'<path d="M5.5 19c1.6-3.2 3.9-4.8 6.5-4.8s4.9 1.6 6.5 4.8"/></svg>';
|
||||
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
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: "▍";
|
||||
|
||||
Reference in New Issue
Block a user