Slack 1.2.0: emoji everywhere, reactions from the transcript, quick emojis, one PIN per page
Emoji: every standard :code: in a message becomes its character (the full Slack table ships as emoji.json, from iamcal/emoji-data, MIT); the workspace's custom emoji come from emoji.list and draw as images proxied through the module; skin tones work on both. Reactions: messages show their reactions as pills (the location's own in the accent). A right-click on a message opens a menu — the admin's quick emojis, then a search over every emoji — and a click on a pill adds or takes back the location's reaction. It goes through the same gate as a send (reactions:write on the app). Rows follow the ProdCom transcript's layout: a square profile image or bot icon (initials on a hue tint when there is none), the name line with its time, the text, the reactions. The accent bar on the location's own messages is gone. Send message: the admin's quick emojis sit left of Send, one press sends one. The PIN pad submits by itself once the PIN's digits are in (the server sends the length, never the PIN) — no ✓ key; in a tile too short for the keypad the dots and a hint remain and the keyboard still works. One PIN unlocks every Slack tile on the page (Send message, Quick replies, reactions) until auto-lock; a remount keeps it, a reload asks again. The transcript's gear lists its three switches without a group. The mock serves reactions.add/remove, emoji.list and profile images. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
70134d6dfc
commit
5dd1b734aa
@@ -0,0 +1,21 @@
|
|||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright (c) 2013 Cal Henderson
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
+708
-143
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
+34
-12
@@ -1,25 +1,31 @@
|
|||||||
{
|
{
|
||||||
"id": "slack",
|
"id": "slack",
|
||||||
"name": "Slack",
|
"name": "Slack",
|
||||||
"version": "1.1.0",
|
"version": "1.2.0",
|
||||||
"proddash": ">=1.4.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, plus PIN-protected sending as the location",
|
||||||
"client": "client.js",
|
"client": "client.js",
|
||||||
"server": "server.js",
|
"server": "server.js",
|
||||||
"style": "style.css",
|
"style": "style.css",
|
||||||
"minSize": { "w": 2, "h": 2 },
|
"minSize": {
|
||||||
"defaultSize": { "w": 4, "h": 4 },
|
"w": 2,
|
||||||
|
"h": 2
|
||||||
|
},
|
||||||
|
"defaultSize": {
|
||||||
|
"w": 4,
|
||||||
|
"h": 4
|
||||||
|
},
|
||||||
"configGroups": {
|
"configGroups": {
|
||||||
"Slack account": {
|
"Slack account": {
|
||||||
"collapsed": "whenSet",
|
"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."
|
"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."
|
||||||
},
|
},
|
||||||
"Channels": {
|
"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."
|
"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": {
|
"Sending": {
|
||||||
"toggle": "sendEnabled",
|
"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. 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."
|
"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."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"configSchema": {
|
"configSchema": {
|
||||||
@@ -75,7 +81,7 @@
|
|||||||
"label": "PIN",
|
"label": "PIN",
|
||||||
"default": "",
|
"default": "",
|
||||||
"group": "Sending",
|
"group": "Sending",
|
||||||
"help": "4–8 digits; asked every time a send tile opens."
|
"help": "4–8 digits; the pad unlocks by itself once they are all in."
|
||||||
},
|
},
|
||||||
"pinIdleMinutes": {
|
"pinIdleMinutes": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
@@ -89,14 +95,30 @@
|
|||||||
"default": "",
|
"default": "",
|
||||||
"group": "Sending",
|
"group": "Sending",
|
||||||
"help": "One per line: Label | Message. Add | #channel-name to bind one to a channel — it then ignores which transcript is selected."
|
"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."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"instanceGroups": {
|
|
||||||
"Transcript": { "collapsed": false }
|
|
||||||
},
|
|
||||||
"instanceSchema": {
|
"instanceSchema": {
|
||||||
"showTimes": { "type": "switch", "label": "Timestamps", "default": true, "group": "Transcript" },
|
"showTimes": {
|
||||||
"showNames": { "type": "switch", "label": "Names", "default": true, "group": "Transcript" },
|
"type": "switch",
|
||||||
"showSystem": { "type": "switch", "label": "System lines", "default": true, "group": "Transcript" }
|
"label": "Timestamps",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"showNames": {
|
||||||
|
"type": "switch",
|
||||||
|
"label": "Names",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"showSystem": {
|
||||||
|
"type": "switch",
|
||||||
|
"label": "System lines",
|
||||||
|
"default": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+304
-12
@@ -17,6 +17,9 @@
|
|||||||
* POST /lock Bearer → forget that unlock token
|
* POST /lock Bearer → forget that unlock token
|
||||||
* GET /quick Bearer → preset labels + indexes (never the message text)
|
* GET /quick Bearer → preset labels + indexes (never the message text)
|
||||||
* POST /send Bearer, { text, channel } → chat.postMessage
|
* 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 }
|
||||||
|
* GET /emoji-image/<name>, GET /avatar/<u/USER|b/BOT> images proxied from Slack's CDN
|
||||||
* POST /quick Bearer, { index, channel } → post that preset
|
* POST /quick Bearer, { index, channel } → post that preset
|
||||||
*
|
*
|
||||||
* `channel` on /send and /quick must be one of the configured channel ids
|
* `channel` on /send and /quick must be one of the configured channel ids
|
||||||
@@ -205,6 +208,10 @@ function describeError(err, ctx = {}) {
|
|||||||
case 'msg_too_long': return 'Message too long for Slack (msg_too_long)';
|
case 'msg_too_long': return 'Message too long for Slack (msg_too_long)';
|
||||||
case 'no_text': return 'Nothing to send (no_text)';
|
case 'no_text': return 'Nothing to send (no_text)';
|
||||||
case 'restricted_action': return `Posting in ${chan} is restricted by a workspace admin (restricted_action)`;
|
case 'restricted_action': return `Posting in ${chan} is restricted by a workspace admin (restricted_action)`;
|
||||||
|
case 'invalid_name': return 'Slack has no emoji by that name (invalid_name)';
|
||||||
|
case 'too_many_reactions': return 'Slack allows 23 different reactions on a message (too_many_reactions)';
|
||||||
|
case 'too_many_emoji': return 'That message already carries as many reactions as Slack allows (too_many_emoji)';
|
||||||
|
case 'message_not_found': return 'That message is gone (message_not_found)';
|
||||||
case 'bad_response': return `Slack answered with something that isn't JSON (HTTP ${err.status})`;
|
case 'bad_response': return `Slack answered with something that isn't JSON (HTTP ${err.status})`;
|
||||||
default: return `Slack error: ${err?.code || err?.message || err}${err?.detail ? ` — ${err.detail}` : ''}`;
|
default: return `Slack error: ${err?.code || err?.message || err}${err?.detail ? ` — ${err.detail}` : ''}`;
|
||||||
}
|
}
|
||||||
@@ -217,14 +224,118 @@ function isConfigError(err) {
|
|||||||
|
|
||||||
/* ── mrkdwn → plain text with a few inline marks ────────────────────── */
|
/* ── mrkdwn → plain text with a few inline marks ────────────────────── */
|
||||||
|
|
||||||
const EMOJI = {
|
/* ── emoji: Slack's :codes: → characters; the workspace's custom set; images ── */
|
||||||
'+1': '👍', thumbsup: '👍', '-1': '👎', thumbsdown: '👎', white_check_mark: '✅', heavy_check_mark: '✔️',
|
|
||||||
x: '❌', pray: '🙏', tada: '🎉', fire: '🔥', eyes: '👀', heart: '❤️', clap: '👏', warning: '⚠️',
|
/** Slack's short names → emoji — modules/slack/emoji.json, built from iamcal/emoji-data (MIT). */
|
||||||
rotating_light: '🚨', smile: '😄', grinning: '😀', joy: '😂', sweat_smile: '😅', thinking_face: '🤔',
|
const EMOJI_NAMES = require('./emoji.json').names;
|
||||||
wave: '👋', ok_hand: '👌', raised_hands: '🙌', bell: '🔔', microphone: '🎤', musical_note: '🎵',
|
/** One code as it sits between colons: "+1", "+1::skin-tone-3", "party_parrot". */
|
||||||
video_camera: '📹', tv: '📺', church: '⛪', zap: '⚡', 100: '💯', point_right: '👉', muscle: '💪',
|
const EMOJI_CODE_RE = /^([a-z0-9_+-]{1,100})(?:::(skin-tone-[2-6]))?$/i;
|
||||||
slightly_smiling_face: '🙂', raised_hand: '✋', satellite_antenna: '📡',
|
/** 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) {
|
||||||
|
const m = EMOJI_CODE_RE.exec(String(code || ''));
|
||||||
|
if (!m) return '';
|
||||||
|
const base = EMOJI_NAMES[m[1].toLowerCase()];
|
||||||
|
if (!base) return '';
|
||||||
|
const tone = m[2] ? EMOJI_NAMES[m[2].toLowerCase()] : '';
|
||||||
|
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;
|
||||||
|
const IMAGE_TTL_MS = 6 * 60 * 60 * 1000;
|
||||||
|
const IMAGE_MAX_BYTES = 2 * 1024 * 1024;
|
||||||
|
const imageCache = new Map(); // url → { buf, type, at } — module-wide, survives a re-init
|
||||||
|
const imageInFlight = new Map();
|
||||||
|
|
||||||
|
function fetchImage(url) {
|
||||||
|
const hit = imageCache.get(url);
|
||||||
|
if (hit && Date.now() - hit.at < IMAGE_TTL_MS) return Promise.resolve(hit);
|
||||||
|
if (imageInFlight.has(url)) return imageInFlight.get(url);
|
||||||
|
const p = (async () => {
|
||||||
|
const response = await fetch(url, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), redirect: 'follow' });
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
const type = String(response.headers.get('content-type') || 'image/png').split(';')[0].trim();
|
||||||
|
if (!type.startsWith('image/')) throw new Error(`not an image (${type})`);
|
||||||
|
const buf = Buffer.from(await response.arrayBuffer());
|
||||||
|
if (buf.length > IMAGE_MAX_BYTES) throw new Error('too large');
|
||||||
|
if (imageCache.size >= IMAGE_CACHE_MAX) imageCache.delete(imageCache.keys().next().value);
|
||||||
|
const entry = { buf, type, at: Date.now() };
|
||||||
|
imageCache.set(url, entry);
|
||||||
|
return entry;
|
||||||
|
})().finally(() => imageInFlight.delete(url));
|
||||||
|
imageInFlight.set(url, p);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only https, or plain http on this machine (the mock) — never a URL a message could smuggle in. */
|
||||||
|
function imageUrlAllowed(url) {
|
||||||
|
try {
|
||||||
|
const u = new URL(String(url || ''));
|
||||||
|
return u.protocol === 'https:' || (u.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(u.hostname));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Answer with the image at `url`: 404 with none, 502 when the CDN fails — the <img> then falls back. */
|
||||||
|
async function serveImage(res, url) {
|
||||||
|
if (!imageUrlAllowed(url)) {
|
||||||
|
res.writeHead(404, { 'Content-Type': 'text/plain', 'Cache-Control': 'no-store' });
|
||||||
|
return void res.end('No image');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const img = await fetchImage(url);
|
||||||
|
res.writeHead(200, { 'Content-Type': img.type, 'Content-Length': img.buf.length, 'Cache-Control': 'private, max-age=3600' });
|
||||||
|
res.end(img.buf);
|
||||||
|
} catch {
|
||||||
|
res.writeHead(502, { 'Content-Type': 'text/plain', 'Cache-Control': 'no-store' });
|
||||||
|
res.end('Image unavailable');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function unescapeSlack(s) {
|
function unescapeSlack(s) {
|
||||||
return s.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&');
|
return s.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&');
|
||||||
@@ -233,7 +344,8 @@ function unescapeSlack(s) {
|
|||||||
/**
|
/**
|
||||||
* Slack mrkdwn → plain text. Mentions, channel links, broadcasts and URLs are
|
* Slack mrkdwn → plain text. Mentions, channel links, broadcasts and URLs are
|
||||||
* spelled out; *bold* _italic_ ~strike~ `code` markers are kept for the
|
* spelled out; *bold* _italic_ ~strike~ `code` markers are kept for the
|
||||||
* client to style; :shortcodes: in the small map become the emoji.
|
* client to style; standard :codes: become their emoji, and a custom one
|
||||||
|
* stays as :code: for the client to draw from the workspace's set.
|
||||||
*/
|
*/
|
||||||
function renderMrkdwn(raw, lookupUser, lookupChannel) {
|
function renderMrkdwn(raw, lookupUser, lookupChannel) {
|
||||||
let text = String(raw || '');
|
let text = String(raw || '');
|
||||||
@@ -260,7 +372,7 @@ function renderMrkdwn(raw, lookupUser, lookupChannel) {
|
|||||||
return url.replace(/^mailto:/, '');
|
return url.replace(/^mailto:/, '');
|
||||||
});
|
});
|
||||||
text = unescapeSlack(text);
|
text = unescapeSlack(text);
|
||||||
text = text.replace(/:([a-z0-9_+-]+):/g, (m, code) => EMOJI[code] || m);
|
text = text.replace(/:([a-z0-9_+-]+(?:::skin-tone-[2-6])?):/gi, (m, code) => emojiChar(code) || m);
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,6 +507,7 @@ module.exports = {
|
|||||||
const pinOk = PIN_RE.test(pin);
|
const pinOk = PIN_RE.test(pin);
|
||||||
const idleMs = Math.min(240, Math.max(1, Number(config.pinIdleMinutes) || 10)) * 60 * 1000;
|
const idleMs = Math.min(240, Math.max(1, Number(config.pinIdleMinutes) || 10)) * 60 * 1000;
|
||||||
const quickReplies = parseQuickReplies(config.quickReplies);
|
const quickReplies = parseQuickReplies(config.quickReplies);
|
||||||
|
const quickEmojis = parseQuickEmojis(config.quickEmojis);
|
||||||
|
|
||||||
/* ── state (all inside this closure so a re-init starts clean) ── */
|
/* ── state (all inside this closure so a re-init starts clean) ── */
|
||||||
let stopped = false;
|
let stopped = false;
|
||||||
@@ -457,6 +570,92 @@ module.exports = {
|
|||||||
return channelNames.get(id) || pollers.get(id)?.name || '';
|
return channelNames.get(id) || pollers.get(id)?.name || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A user's picture as Slack lists it (the 72 px one fits a transcript row). */
|
||||||
|
function profileImage(u) {
|
||||||
|
const p = u?.profile || {};
|
||||||
|
return String(p.image_72 || p.image_48 || p.image_32 || p.image_original || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bot messages carry their own icon; remembered by bot so a later message
|
||||||
|
from the same bot without icons (a plain bot_message) still has one. */
|
||||||
|
const botIcons = new Map(); // bot id / username → image url
|
||||||
|
function botIconOf(m) {
|
||||||
|
const icons = m.icons || m.bot_profile?.icons || {};
|
||||||
|
const url = String(icons.image_72 || icons.image_64 || icons.image_48 || icons.image_36 || '');
|
||||||
|
const key = String(m.bot_id || m.username || m.bot_profile?.id || 'bot');
|
||||||
|
if (url) botIcons.set(key, url);
|
||||||
|
const emoji = icons.emoji ? emojiChar(String(icons.emoji).replace(/^:|:$/g, '')) : '';
|
||||||
|
return { key, url: url || botIcons.get(key) || '', emoji };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [{ name, count, me }] for a message's reactions, or nothing. `me` = this account reacted. */
|
||||||
|
function reactionsOf(m) {
|
||||||
|
if (!Array.isArray(m.reactions) || !m.reactions.length) return undefined;
|
||||||
|
const meId = identity?.userId || '';
|
||||||
|
const out = [];
|
||||||
|
for (const r of m.reactions) {
|
||||||
|
const name = String(r?.name || '');
|
||||||
|
if (!EMOJI_CODE_RE.test(name)) continue;
|
||||||
|
const users = Array.isArray(r.users) ? r.users : [];
|
||||||
|
const count = Math.max(0, Math.trunc(Number(r.count)) || users.length);
|
||||||
|
if (count > 0) out.push({ name, count, me: Boolean(meId && users.includes(meId)) });
|
||||||
|
}
|
||||||
|
return out.length ? out : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── the workspace's custom emoji (emoji.list, hourly) ── */
|
||||||
|
let customEmoji = new Map(); // name → { url } | { char } (an alias of a standard emoji)
|
||||||
|
let customEmojiSig = '';
|
||||||
|
let emojiVersion = 0; // bumps when the set changes; tiles refetch /emoji then
|
||||||
|
let emojiCheckedAt = 0;
|
||||||
|
let emojiPromise = null;
|
||||||
|
let emojiScopeMissing = false;
|
||||||
|
|
||||||
|
function loadCustomEmoji() {
|
||||||
|
if (emojiScopeMissing || Date.now() - emojiCheckedAt < EMOJI_LIST_TTL_MS) return Promise.resolve();
|
||||||
|
if (!emojiPromise) {
|
||||||
|
emojiPromise = (async () => {
|
||||||
|
emojiCheckedAt = Date.now();
|
||||||
|
const data = await slackCall(token, 'emoji.list', {});
|
||||||
|
const raw = data.emoji && typeof data.emoji === 'object' ? data.emoji : {};
|
||||||
|
const resolve = (name, depth) => {
|
||||||
|
const v = raw[name];
|
||||||
|
if (typeof v !== 'string' || depth > 5) return null;
|
||||||
|
if (v.startsWith('alias:')) {
|
||||||
|
const to = v.slice('alias:'.length);
|
||||||
|
if (to in raw) return resolve(to, depth + 1);
|
||||||
|
const ch = emojiChar(to);
|
||||||
|
return ch ? { char: ch } : null;
|
||||||
|
}
|
||||||
|
return imageUrlAllowed(v) ? { url: v } : null;
|
||||||
|
};
|
||||||
|
const next = new Map();
|
||||||
|
for (const name of Object.keys(raw).sort()) {
|
||||||
|
if (!EMOJI_CODE_RE.test(name) || name.includes('::')) continue;
|
||||||
|
const e = resolve(name, 0);
|
||||||
|
if (e) next.set(name, e);
|
||||||
|
}
|
||||||
|
const sig = JSON.stringify([...next]);
|
||||||
|
if (sig !== customEmojiSig) {
|
||||||
|
customEmoji = next;
|
||||||
|
customEmojiSig = sig;
|
||||||
|
emojiVersion += 1;
|
||||||
|
invalidateAll();
|
||||||
|
log(`${next.size} custom emoji in the workspace`);
|
||||||
|
}
|
||||||
|
})().catch((err) => {
|
||||||
|
if (err.code === 'missing_scope') {
|
||||||
|
emojiScopeMissing = true;
|
||||||
|
log('custom emoji stay as :codes: — the token lacks the emoji:read scope (add it under OAuth & Permissions, reinstall, paste the new token); standard emoji are unaffected');
|
||||||
|
} else {
|
||||||
|
emojiCheckedAt = Date.now() - EMOJI_LIST_TTL_MS + RETRY_NETWORK_MS; // ask again soon
|
||||||
|
if (err.code !== 'ratelimited') log(`emoji.list: ${describeError(err, { name: identity?.name })}`);
|
||||||
|
}
|
||||||
|
}).finally(() => { emojiPromise = null; });
|
||||||
|
}
|
||||||
|
return emojiPromise;
|
||||||
|
}
|
||||||
|
|
||||||
function invalidateAll() {
|
function invalidateAll() {
|
||||||
for (const ch of pollers.values()) ch.messagesCache = null;
|
for (const ch of pollers.values()) ch.messagesCache = null;
|
||||||
}
|
}
|
||||||
@@ -471,7 +670,7 @@ module.exports = {
|
|||||||
const data = await slackCall(token, 'users.info', { user: id });
|
const data = await slackCall(token, 'users.info', { user: id });
|
||||||
const u = data.user || {};
|
const u = data.user || {};
|
||||||
const name = String(u.profile?.display_name || u.real_name || u.profile?.real_name || u.name || id);
|
const name = String(u.profile?.display_name || u.real_name || u.profile?.real_name || u.name || id);
|
||||||
users.set(id, { name, at: Date.now() });
|
users.set(id, { name, image: profileImage(u), at: Date.now() });
|
||||||
changed = true;
|
changed = true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Unknown or deactivated: remember the raw id for a while so we
|
// Unknown or deactivated: remember the raw id for a while so we
|
||||||
@@ -571,6 +770,7 @@ module.exports = {
|
|||||||
let text = textFor(m);
|
let text = textFor(m);
|
||||||
if (m.subtype === 'channel_join' || m.subtype === 'group_join') text = `${name} joined ${chanLabel}`;
|
if (m.subtype === 'channel_join' || m.subtype === 'group_join') text = `${name} joined ${chanLabel}`;
|
||||||
else if (m.subtype === 'channel_leave' || m.subtype === 'group_leave') text = `${name} left ${chanLabel}`;
|
else if (m.subtype === 'channel_leave' || m.subtype === 'group_leave') text = `${name} left ${chanLabel}`;
|
||||||
|
const icon = m.user ? null : botIconOf(m);
|
||||||
const out = {
|
const out = {
|
||||||
id: String(m.ts),
|
id: String(m.ts),
|
||||||
ts: String(m.ts),
|
ts: String(m.ts),
|
||||||
@@ -582,6 +782,11 @@ module.exports = {
|
|||||||
subtype: String(m.subtype || ''),
|
subtype: String(m.subtype || ''),
|
||||||
bot: Boolean(m.bot_id) || m.subtype === 'bot_message',
|
bot: Boolean(m.bot_id) || m.subtype === 'bot_message',
|
||||||
me: Boolean(identity && m.user && m.user === identity.userId),
|
me: Boolean(identity && m.user && m.user === identity.userId),
|
||||||
|
// the picture in the row: a user's profile image or a bot's icon, by
|
||||||
|
// key for GET /avatar/<key>; a bot with only an :emoji: icon shows that
|
||||||
|
avatar: m.user ? `u/${m.user}` : icon?.url ? `b/${icon.key}` : '',
|
||||||
|
avatarEmoji: icon?.emoji || '',
|
||||||
|
reactions: reactionsOf(m),
|
||||||
};
|
};
|
||||||
if (parentTs) out.parent = parentTs;
|
if (parentTs) out.parent = parentTs;
|
||||||
else {
|
else {
|
||||||
@@ -689,6 +894,10 @@ module.exports = {
|
|||||||
historyCount,
|
historyCount,
|
||||||
pollSeconds: intervalMs / 1000,
|
pollSeconds: intervalMs / 1000,
|
||||||
sending: { enabled: sendEnabled, pinSet: pinOk, idleMinutes: idleMs / 60000 },
|
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 },
|
status: { state: h.status, message: h.message, lastPollAt },
|
||||||
apiBase: API_BASE,
|
apiBase: API_BASE,
|
||||||
};
|
};
|
||||||
@@ -771,7 +980,7 @@ module.exports = {
|
|||||||
const u = await slackCall(token, 'users.info', { user: userId });
|
const u = await slackCall(token, 'users.info', { user: userId });
|
||||||
const name = String(u.user?.profile?.display_name || u.user?.real_name || u.user?.profile?.real_name || '').trim();
|
const name = String(u.user?.profile?.display_name || u.user?.real_name || u.user?.profile?.real_name || '').trim();
|
||||||
if (name) id.name = name;
|
if (name) id.name = name;
|
||||||
users.set(userId, { name: id.name, at: Date.now() });
|
users.set(userId, { name: id.name, image: profileImage(u.user), at: Date.now() });
|
||||||
} catch { /* the handle will do */ }
|
} catch { /* the handle will do */ }
|
||||||
}
|
}
|
||||||
identity = id;
|
identity = id;
|
||||||
@@ -875,6 +1084,7 @@ module.exports = {
|
|||||||
retry = wait + 100 + ch.index * 100;
|
retry = wait + 100 + ch.index * 100;
|
||||||
} else {
|
} else {
|
||||||
await ensureAuth();
|
await ensureAuth();
|
||||||
|
await loadCustomEmoji();
|
||||||
if (!ch.checked || ch.errorKind === 'membership') await checkChannel(ch);
|
if (!ch.checked || ch.errorKind === 'membership') await checkChannel(ch);
|
||||||
await readHistory(ch);
|
await readHistory(ch);
|
||||||
if (ch.error) log(`${labelOf(ch)}: reading again`);
|
if (ch.error) log(`${labelOf(ch)}: reading again`);
|
||||||
@@ -973,6 +1183,41 @@ module.exports = {
|
|||||||
return slackCall(token, 'chat.postMessage', params, { json: true });
|
return slackCall(token, 'chat.postMessage', params, { json: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
async function react(req, res, unlock, { 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?' };
|
||||||
|
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?' });
|
||||||
|
const code = String(name ?? '').trim().replace(/^:|:$/g, '').toLowerCase();
|
||||||
|
if (!EMOJI_CODE_RE.test(code)) return sendJson(res, 400, { error: 'Not an emoji name' });
|
||||||
|
const targetName = lookupChannel(target.id) || target.id;
|
||||||
|
try {
|
||||||
|
await slackCall(token, remove ? 'reactions.remove' : 'reactions.add', { channel: target.id, timestamp: stamp, name: code });
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code !== 'already_reacted' && err.code !== 'no_reaction') {
|
||||||
|
const message = describeError(err, { name: identity.name, channel: targetName });
|
||||||
|
log(`reaction :${code}: in #${targetName} failed: ${message}`);
|
||||||
|
const status = ['invalid_name', 'too_many_reactions', 'too_many_emoji'].includes(err.code) ? 400
|
||||||
|
: err.code === 'message_not_found' ? 404 : 502;
|
||||||
|
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}` });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Post on behalf of an unlock; answers the response. `targetId` is an
|
* Post on behalf of an unlock; answers the response. `targetId` is an
|
||||||
* already-validated configured channel; `presetChannel` (a quick reply's
|
* already-validated configured channel; `presetChannel` (a quick reply's
|
||||||
@@ -1055,6 +1300,23 @@ module.exports = {
|
|||||||
quickReplies,
|
quickReplies,
|
||||||
sendGate,
|
sendGate,
|
||||||
deliver,
|
deliver,
|
||||||
|
react,
|
||||||
|
/** Everything a tile needs to draw emoji: the shipped table plus the workspace's own. */
|
||||||
|
emojiTable() {
|
||||||
|
const custom = {};
|
||||||
|
for (const [name, e] of customEmoji) {
|
||||||
|
custom[name] = e.url ? { url: `/api/modules/slack/emoji-image/${encodeURIComponent(name)}` } : { char: e.char };
|
||||||
|
}
|
||||||
|
return { standard: EMOJI_NAMES, custom, version: emojiVersion };
|
||||||
|
},
|
||||||
|
customEmojiUrl: (name) => customEmoji.get(String(name || ''))?.url || '',
|
||||||
|
/** "u/U123" → that user's profile image; "b/<bot>" → that bot's icon. */
|
||||||
|
avatarUrl(key) {
|
||||||
|
const [kind, id] = String(key || '').split('/');
|
||||||
|
if (kind === 'u') return users.get(id)?.image || '';
|
||||||
|
if (kind === 'b') return botIcons.get(id) || '';
|
||||||
|
return '';
|
||||||
|
},
|
||||||
unlockFor,
|
unlockFor,
|
||||||
chooseTarget,
|
chooseTarget,
|
||||||
namesReady,
|
namesReady,
|
||||||
@@ -1238,6 +1500,36 @@ module.exports = {
|
|||||||
current.lock(req, 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);
|
||||||
|
sendJson(res, 200, current.emojiTable());
|
||||||
|
},
|
||||||
|
|
||||||
|
// Pictures proxied from Slack's CDN: a custom emoji by name, a profile image or bot icon by key.
|
||||||
|
'GET /emoji-image/*': guard(async (req, res) => {
|
||||||
|
if (!current) return unmounted(res);
|
||||||
|
await serveImage(res, current.customEmojiUrl(decodeURIComponent(String(req.wildcard || '').replace(/^\/+/, ''))));
|
||||||
|
}),
|
||||||
|
'GET /avatar/*': guard(async (req, res) => {
|
||||||
|
if (!current) return unmounted(res);
|
||||||
|
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) => {
|
||||||
|
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.
|
// Labels and indexes only — the message text never leaves the server.
|
||||||
'GET /quick': (req, res) => {
|
'GET /quick': (req, res) => {
|
||||||
if (!current) return unmounted(res);
|
if (!current) return unmounted(res);
|
||||||
|
|||||||
+244
-15
@@ -1,5 +1,7 @@
|
|||||||
/* Slack module — scoped to its tiles, shell variables only.
|
/* Slack module — scoped to its tiles, shell variables only.
|
||||||
--sl-size (per tile, from the Text size setting) drives every font size. */
|
--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. */
|
||||||
|
|
||||||
[data-module="slack"] .sl-wrap {
|
[data-module="slack"] .sl-wrap {
|
||||||
--sl-size: 14px;
|
--sl-size: 14px;
|
||||||
@@ -74,9 +76,9 @@
|
|||||||
[data-module="slack"] .sl-empty.is-error { color: var(--danger); }
|
[data-module="slack"] .sl-empty.is-error { color: var(--danger); }
|
||||||
|
|
||||||
[data-module="slack"] .sl-entry {
|
[data-module="slack"] .sl-entry {
|
||||||
padding: 4px 6px;
|
padding: 5px 6px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
line-height: 1.4;
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
[data-module="slack"] .sl-entry:hover { background: var(--bg); }
|
[data-module="slack"] .sl-entry:hover { background: var(--bg); }
|
||||||
[data-module="slack"] .sl-entry.sl-new { animation: sl-fade-in 0.25s ease-out; }
|
[data-module="slack"] .sl-entry.sl-new { animation: sl-fade-in 0.25s ease-out; }
|
||||||
@@ -85,9 +87,46 @@
|
|||||||
to { opacity: 1; transform: none; }
|
to { opacity: 1; transform: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* a message from this machine's own account */
|
/* one row: the picture, then everything else */
|
||||||
[data-module="slack"] .sl-entry.sl-me {
|
[data-module="slack"] .sl-msg,
|
||||||
box-shadow: inset 3px 0 0 color-mix(in srgb, var(--accent) 55%, transparent);
|
[data-module="slack"] .sl-reply {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
[data-module="slack"] .sl-body { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
|
/* the square: a profile image, a bot's icon or emoji, or initials on a tint
|
||||||
|
of a hue the name hashes to — the same size and corner as ProdCom's */
|
||||||
|
[data-module="slack"] .sl-avatar {
|
||||||
|
flex: none;
|
||||||
|
width: calc(var(--sl-size) * 1.9);
|
||||||
|
height: calc(var(--sl-size) * 1.9);
|
||||||
|
border-radius: 26%;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: hsl(var(--sl-hue, 150) 45% 50% / 0.2);
|
||||||
|
color: hsl(var(--sl-hue, 150) 55% 60%);
|
||||||
|
font-size: 0.72em;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
[data-module="slack"] .sl-avatar-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
[data-module="slack"] .sl-avatar-emoji { font-size: 1.25em; line-height: 1; }
|
||||||
|
[data-module="slack"] .sl-reply .sl-avatar {
|
||||||
|
width: calc(var(--sl-size) * 1.5);
|
||||||
|
height: calc(var(--sl-size) * 1.5);
|
||||||
|
font-size: 0.6em;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-module="slack"] .sl-meta {
|
[data-module="slack"] .sl-meta {
|
||||||
@@ -95,11 +134,12 @@
|
|||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
margin-bottom: 1px;
|
margin-bottom: 2px;
|
||||||
}
|
}
|
||||||
[data-module="slack"] .sl-name {
|
[data-module="slack"] .sl-name {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 0.86em;
|
font-size: 0.82em;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -148,6 +188,52 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* a custom emoji is an image the size of a character */
|
||||||
|
[data-module="slack"] .sl-emoji {
|
||||||
|
display: inline-block;
|
||||||
|
width: 1.25em;
|
||||||
|
height: 1.25em;
|
||||||
|
object-fit: contain;
|
||||||
|
vertical-align: -0.28em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* reactions under a message: pills with the emoji and its count; the
|
||||||
|
location's own carry the accent — a click adds yours or takes it back */
|
||||||
|
[data-module="slack"] .sl-reactions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
[data-module="slack"] .sl-reaction {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 1px 7px 1px 5px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--text) 5%, transparent);
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.78em;
|
||||||
|
line-height: 1.4;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s, border-color 0.1s;
|
||||||
|
}
|
||||||
|
[data-module="slack"] .sl-reaction:hover { border-color: var(--accent); }
|
||||||
|
[data-module="slack"] .sl-reaction.is-me {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: color-mix(in srgb, var(--accent) 16%, transparent);
|
||||||
|
}
|
||||||
|
[data-module="slack"] .sl-reaction .sl-emoji,
|
||||||
|
[data-module="slack"] .sl-reaction .sl-emoji-char { font-size: 1.15em; line-height: 1; vertical-align: middle; }
|
||||||
|
[data-module="slack"] .sl-reaction-count {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
[data-module="slack"] .sl-reaction.is-me .sl-reaction-count { color: var(--text); }
|
||||||
|
|
||||||
/* thread replies nest under their parent; without them, a muted count */
|
/* thread replies nest under their parent; without them, a muted count */
|
||||||
[data-module="slack"] .sl-replies {
|
[data-module="slack"] .sl-replies {
|
||||||
margin: 4px 0 0 10px;
|
margin: 4px 0 0 10px;
|
||||||
@@ -293,13 +379,8 @@
|
|||||||
[data-module="slack"] .sl-key:active { background: color-mix(in srgb, var(--accent) 30%, var(--panel)); }
|
[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 svg { width: 1.1em; height: 1.1em; }
|
||||||
[data-module="slack"] .sl-key-back { color: var(--muted); }
|
[data-module="slack"] .sl-key-back { color: var(--muted); }
|
||||||
[data-module="slack"] .sl-key-ok { background: var(--accent); color: var(--accent-text); border-color: transparent; }
|
[data-module="slack"] .sl-key-blank { visibility: hidden; }
|
||||||
[data-module="slack"] .sl-key-ok:hover { background: color-mix(in srgb, var(--accent) 85%, var(--text)); }
|
[data-module="slack"] .sl-pad.is-busy .sl-keys { opacity: 0.6; pointer-events: none; }
|
||||||
[data-module="slack"] .sl-key-ok:disabled {
|
|
||||||
background: color-mix(in srgb, var(--accent) 25%, var(--panel));
|
|
||||||
color: var(--muted);
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-module="slack"] .sl-pad-msg {
|
[data-module="slack"] .sl-pad-msg {
|
||||||
flex: none;
|
flex: none;
|
||||||
@@ -311,6 +392,24 @@
|
|||||||
}
|
}
|
||||||
[data-module="slack"] .sl-pad-msg.is-error { color: var(--danger); font-weight: 600; }
|
[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: 185px) {
|
||||||
|
[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 ── */
|
/* ── send / quick replies: unlocked ── */
|
||||||
|
|
||||||
[data-module="slack"] .sl-compose { gap: 6px; }
|
[data-module="slack"] .sl-compose { gap: 6px; }
|
||||||
@@ -396,6 +495,40 @@
|
|||||||
[data-module="slack"] .sl-sendbtn:hover { background: color-mix(in srgb, var(--accent) 85%, var(--text)); }
|
[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; }
|
[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 */
|
/* the preset grid: big targets, labels wrap */
|
||||||
[data-module="slack"] .sl-grid {
|
[data-module="slack"] .sl-grid {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -445,3 +578,99 @@
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
[data-module="slack"] .sl-quick.is-sent .sl-quick-chan { color: inherit; }
|
[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 {
|
||||||
|
--sl-size: 14px;
|
||||||
|
position: fixed;
|
||||||
|
z-index: 90;
|
||||||
|
width: 292px;
|
||||||
|
max-width: calc(100vw - 16px);
|
||||||
|
max-height: calc(100vh - 16px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: var(--sl-size);
|
||||||
|
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.55);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
[data-module="slack"].sl-emoji-menu:focus { outline: none; }
|
||||||
|
[data-module="slack"].sl-emoji-menu.is-busy { opacity: 0.7; pointer-events: none; }
|
||||||
|
[data-module="slack"] .sl-emoji-head {
|
||||||
|
font-size: 0.8em;
|
||||||
|
color: var(--muted);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
[data-module="slack"] .sl-emoji-quickrow {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
[data-module="slack"] .sl-emoji-quickrow:empty { display: none; }
|
||||||
|
[data-module="slack"] .sl-emoji-btn {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: none;
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background 0.1s, border-color 0.1s;
|
||||||
|
}
|
||||||
|
[data-module="slack"] .sl-emoji-quickrow .sl-emoji-btn { font-size: 22px; width: 38px; height: 38px; background: color-mix(in srgb, var(--text) 5%, var(--panel)); border-color: var(--border); }
|
||||||
|
[data-module="slack"] .sl-emoji-btn:hover { background: var(--border); border-color: var(--accent); }
|
||||||
|
[data-module="slack"] .sl-emoji-btn .sl-emoji { width: 1.1em; height: 1.1em; vertical-align: middle; }
|
||||||
|
[data-module="slack"] .sl-emoji-search {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 6px 9px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
[data-module="slack"] .sl-emoji-search:focus { outline: none; border-color: var(--accent); }
|
||||||
|
[data-module="slack"] .sl-emoji-results {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 2px;
|
||||||
|
max-height: 150px;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 34px;
|
||||||
|
}
|
||||||
|
[data-module="slack"] .sl-emoji-none {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 4px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.8em;
|
||||||
|
}
|
||||||
|
[data-module="slack"] .sl-emoji-msg {
|
||||||
|
min-height: 1em;
|
||||||
|
font-size: 0.78em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
[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; }
|
||||||
|
|||||||
+87
-3
@@ -18,10 +18,16 @@
|
|||||||
* conversations.history newest-first window (limit); not_in_channel for
|
* conversations.history newest-first window (limit); not_in_channel for
|
||||||
* #announcements
|
* #announcements
|
||||||
* conversations.replies a thread's parent + replies
|
* conversations.replies a thread's parent + replies
|
||||||
* users.info display names for the seeded users
|
* users.info display names and profile images (SVG squares
|
||||||
|
* served by this mock under /img/u/<id>.svg)
|
||||||
* chat.postMessage appends to the channel (echoes `username` for
|
* chat.postMessage appends to the channel (echoes `username` for
|
||||||
* bots as a bot_message); text containing "!429"
|
* bots as a bot_message); text containing "!429"
|
||||||
* answers HTTP 429 with Retry-After: 3
|
* answers HTTP 429 with Retry-After: 3
|
||||||
|
* emoji.list four custom emoji: two images (/img/e/<name>.svg),
|
||||||
|
* an alias of a standard emoji, an alias of a custom
|
||||||
|
* reactions.add/.remove on a message (or reply) by channel + timestamp;
|
||||||
|
* already_reacted / no_reaction / message_not_found
|
||||||
|
* as Slack answers them; name "nope" → invalid_name
|
||||||
*
|
*
|
||||||
* #production is seeded with mrkdwn, mentions, a link, a thread, a bot
|
* #production is seeded with mrkdwn, mentions, a link, a thread, a bot
|
||||||
* attachment and a file; it gains a new fake message every ~8 s, edits one
|
* attachment and a file; it gains a new fake message every ~8 s, edits one
|
||||||
@@ -78,6 +84,53 @@ function push(channelId, msg) {
|
|||||||
return m;
|
return m;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A message or a reply by timestamp, anywhere in a channel. */
|
||||||
|
function findMessage(channelId, stamp) {
|
||||||
|
for (const m of history.get(channelId) || []) {
|
||||||
|
if (m.ts === stamp) return m;
|
||||||
|
for (const r of m.replies || []) if (r.ts === stamp) return r;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Slack's reactions array: [{ name, users, count }], edited in place. */
|
||||||
|
function addReaction(m, name, userId) {
|
||||||
|
m.reactions = m.reactions || [];
|
||||||
|
let r = m.reactions.find((x) => x.name === name);
|
||||||
|
if (r && r.users.includes(userId)) return 'already_reacted';
|
||||||
|
if (!r) {
|
||||||
|
r = { name, users: [], count: 0 };
|
||||||
|
m.reactions.push(r);
|
||||||
|
}
|
||||||
|
r.users.push(userId);
|
||||||
|
r.count = r.users.length;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeReaction(m, name, userId) {
|
||||||
|
const r = (m.reactions || []).find((x) => x.name === name);
|
||||||
|
if (!r || !r.users.includes(userId)) return 'no_reaction';
|
||||||
|
r.users = r.users.filter((u) => u !== userId);
|
||||||
|
r.count = r.users.length;
|
||||||
|
if (!r.count) m.reactions = m.reactions.filter((x) => x !== r);
|
||||||
|
if (!m.reactions.length) delete m.reactions;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A square SVG with an initial, one hue per id — the mock's stand-in for a
|
||||||
|
profile image or a custom emoji picture. */
|
||||||
|
const IMAGE_HUES = { U001: 150, U002: 210, U003: 330, U004: 30, U005: 270, UBOT: 0, party_parrot: 120, waters: 200 };
|
||||||
|
function squareSvg(id, letter) {
|
||||||
|
const hue = IMAGE_HUES[id] ?? 180;
|
||||||
|
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 72 72"><rect width="72" height="72" rx="18" fill="hsl(${hue} 50% 45%)"/><text x="36" y="47" text-anchor="middle" font-family="system-ui, sans-serif" font-size="34" font-weight="700" fill="#fff">${letter}</text></svg>`;
|
||||||
|
}
|
||||||
|
const CUSTOM_EMOJI = {
|
||||||
|
party_parrot: (port) => `http://127.0.0.1:${port}/img/e/party_parrot.svg`,
|
||||||
|
waters: (port) => `http://127.0.0.1:${port}/img/e/waters.svg`,
|
||||||
|
thumbsup_all: () => 'alias:+1',
|
||||||
|
parrot: () => 'alias:party_parrot',
|
||||||
|
};
|
||||||
|
|
||||||
function seed() {
|
function seed() {
|
||||||
const min = 60000;
|
const min = 60000;
|
||||||
const at = (minsAgo) => ts(-minsAgo * min);
|
const at = (minsAgo) => ts(-minsAgo * min);
|
||||||
@@ -97,7 +150,9 @@ function seed() {
|
|||||||
attachments: [{ fallback: 'Plan updated: Sunday Service — Sept 14 (2 items changed)', title: 'Plan updated', text: 'Sunday Service — Sept 14 (2 items changed)' }],
|
attachments: [{ fallback: 'Plan updated: Sunday Service — Sept 14 (2 items changed)', title: 'Plan updated', text: 'Sunday Service — Sept 14 (2 items changed)' }],
|
||||||
});
|
});
|
||||||
push('C001', { ts: at(30), user: 'U005', text: 'Lower thirds for the announcements: <https://waterschurch.org/plan|this week’s plan>', files: [{ id: 'F001', name: 'lower-thirds-0914.png', title: 'lower-thirds-0914.png' }] });
|
push('C001', { ts: at(30), user: 'U005', text: 'Lower thirds for the announcements: <https://waterschurch.org/plan|this week’s plan>', files: [{ id: 'F001', name: 'lower-thirds-0914.png', title: 'lower-thirds-0914.png' }] });
|
||||||
push('C001', { ts: at(25), user: 'U004', text: 'Audio fixed — Dante patch was it. Thanks <@U002> :pray:', edited: { user: 'U004', ts: at(24) } });
|
const fixed = push('C001', { ts: at(25), user: 'U004', text: 'Audio fixed — Dante patch was it. Thanks <@U002> :pray:', edited: { user: 'U004', ts: at(24) } });
|
||||||
|
fixed.reactions = [{ name: '+1', users: ['U002', 'U003', 'U001'], count: 3 }, { name: 'party_parrot', users: ['U005'], count: 1 }, { name: 'pray::skin-tone-3', users: ['U002'], count: 1 }];
|
||||||
|
push('C001', { ts: at(23), user: 'U002', text: 'Custom emoji check :party_parrot: :waters: and an unknown :not_an_emoji: stay put' });
|
||||||
push('C001', { ts: at(20), user: 'U002', text: '<!here> 10 minutes to doors. Campuses, post "ready" when your pre-service loop is running.' });
|
push('C001', { ts: at(20), user: 'U002', text: '<!here> 10 minutes to doors. Campuses, post "ready" when your pre-service loop is running.' });
|
||||||
push('C001', { ts: at(15), user: 'U001', text: 'Apollo Beach ready' });
|
push('C001', { ts: at(15), user: 'U001', text: 'Apollo Beach ready' });
|
||||||
push('C001', { ts: at(14), user: 'U004', text: 'North Attleboro ready' });
|
push('C001', { ts: at(14), user: 'U004', text: 'North Attleboro ready' });
|
||||||
@@ -245,6 +300,13 @@ const server = http.createServer(async (req, res) => {
|
|||||||
const summary = Object.entries(params).filter(([k]) => k !== 'token').map(([k, v]) => `${k}=${String(v).slice(0, 40)}`).join(' ');
|
const summary = Object.entries(params).filter(([k]) => k !== 'token').map(([k, v]) => `${k}=${String(v).slice(0, 40)}`).join(' ');
|
||||||
console.log(`[slack-mock] ${req.method} ${url.pathname} ${summary}`);
|
console.log(`[slack-mock] ${req.method} ${url.pathname} ${summary}`);
|
||||||
|
|
||||||
|
const img = url.pathname.match(/^\/img\/(u|e)\/([A-Za-z0-9_+-]+)\.svg$/);
|
||||||
|
if (img) {
|
||||||
|
const id = img[2];
|
||||||
|
const letter = img[1] === 'u' ? (USERS[id]?.display_name || USERS[id]?.real_name || id)[0].toUpperCase() : id[0].toUpperCase();
|
||||||
|
res.writeHead(200, { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'no-store' });
|
||||||
|
return res.end(squareSvg(id, letter));
|
||||||
|
}
|
||||||
if (!url.pathname.startsWith('/api/')) return send(res, 404, { ok: false, error: 'unknown_method' });
|
if (!url.pathname.startsWith('/api/')) return send(res, 404, { ok: false, error: 'unknown_method' });
|
||||||
|
|
||||||
const token = tokenOf(req, params);
|
const token = tokenOf(req, params);
|
||||||
@@ -260,7 +322,8 @@ const server = http.createServer(async (req, res) => {
|
|||||||
case 'users.info': {
|
case 'users.info': {
|
||||||
const u = USERS[String(params.user)];
|
const u = USERS[String(params.user)];
|
||||||
if (!u) return send(res, 200, { ok: false, error: 'user_not_found' });
|
if (!u) return send(res, 200, { ok: false, error: 'user_not_found' });
|
||||||
return send(res, 200, { ok: true, user: { id: params.user, name: u.name, real_name: u.real_name, is_bot: Boolean(u.is_bot), profile: { display_name: u.display_name, real_name: u.real_name } } });
|
const img = `http://127.0.0.1:${PORT}/img/u/${params.user}.svg`;
|
||||||
|
return send(res, 200, { ok: true, user: { id: params.user, name: u.name, real_name: u.real_name, is_bot: Boolean(u.is_bot), profile: { display_name: u.display_name, real_name: u.real_name, image_48: img, image_72: img } } });
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'conversations.list': {
|
case 'conversations.list': {
|
||||||
@@ -301,6 +364,27 @@ const server = http.createServer(async (req, res) => {
|
|||||||
return send(res, 200, { ok: true, messages: [publicMessage(parent), ...(parent.replies || []).slice(0, limit - 1)], has_more: false });
|
return send(res, 200, { ok: true, messages: [publicMessage(parent), ...(parent.replies || []).slice(0, limit - 1)], has_more: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'emoji.list': {
|
||||||
|
const emoji = {};
|
||||||
|
for (const [name, v] of Object.entries(CUSTOM_EMOJI)) emoji[name] = v(PORT);
|
||||||
|
return send(res, 200, { ok: true, emoji, cache_ts: '1700000000.000000' });
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'reactions.add':
|
||||||
|
case 'reactions.remove': {
|
||||||
|
const c = findChannel(params.channel);
|
||||||
|
if (!c) return send(res, 200, { ok: false, error: 'channel_not_found' });
|
||||||
|
if (!c.is_member) return send(res, 200, { ok: false, error: 'not_in_channel' });
|
||||||
|
const name = String(params.name || '');
|
||||||
|
if (!name || name === 'nope') return send(res, 200, { ok: false, error: 'invalid_name' });
|
||||||
|
const m = findMessage(c.id, String(params.timestamp || ''));
|
||||||
|
if (!m) return send(res, 200, { ok: false, error: 'message_not_found' });
|
||||||
|
const err = method === 'reactions.add' ? addReaction(m, name, who.user_id) : removeReaction(m, name, who.user_id);
|
||||||
|
if (err) return send(res, 200, { ok: false, error: err });
|
||||||
|
console.log(`[slack-mock] ${method === 'reactions.add' ? '➕' : '➖'} :${name}: on ${m.ts} in #${c.name} by ${who.user}`);
|
||||||
|
return send(res, 200, { ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
case 'chat.postMessage': {
|
case 'chat.postMessage': {
|
||||||
const c = findChannel(params.channel);
|
const c = findChannel(params.channel);
|
||||||
if (!c) return send(res, 200, { ok: false, error: 'channel_not_found' });
|
if (!c) return send(res, 200, { ok: false, error: 'channel_not_found' });
|
||||||
|
|||||||
Reference in New Issue
Block a user