@firedrill-tools/slack 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +369 -0
  3. package/firedrill/agent.target.json +17 -0
  4. package/firedrill/baseline.scenario.json +5 -0
  5. package/firedrill/bounds.scenario.json +112 -0
  6. package/firedrill/conformance.suite.json +21 -0
  7. package/firedrill/history-unavailable.scenario.json +11 -0
  8. package/firedrill/open-long-handles.scenario.json +263 -0
  9. package/firedrill/open-member-bound.scenario.json +24 -0
  10. package/firedrill/post-rate-limited.scenario.json +11 -0
  11. package/firedrill/response-budget.scenario.json +24 -0
  12. package/firedrill/slack-bounds.drill.json +428 -0
  13. package/firedrill/slack-denied.drill.json +74 -0
  14. package/firedrill/slack-fresh-install.drill.json +132 -0
  15. package/firedrill/slack-history-unavailable.drill.json +83 -0
  16. package/firedrill/slack-invalid-auth.drill.json +408 -0
  17. package/firedrill/slack-mcp-aliases.drill.json +219 -0
  18. package/firedrill/slack-open-long-handles.drill.json +82 -0
  19. package/firedrill/slack-open-member-bound.drill.json +135 -0
  20. package/firedrill/slack-post-rate-limited.drill.json +96 -0
  21. package/firedrill/slack-response-budget.drill.json +118 -0
  22. package/firedrill/slack-thread-many-repliers.drill.json +100 -0
  23. package/firedrill/slack-ts-sequence-full.drill.json +165 -0
  24. package/firedrill/slack-visibility.drill.json +110 -0
  25. package/firedrill/slack-web-api-flow.drill.json +896 -0
  26. package/firedrill/thread-many-repliers.scenario.json +79 -0
  27. package/firedrill/tools/slack/app/assets/ATTRIBUTION.md +40 -0
  28. package/firedrill/tools/slack/app/assets/fonts/OFL.txt +93 -0
  29. package/firedrill/tools/slack/app/assets/fonts/lato-latin-400.woff2 +0 -0
  30. package/firedrill/tools/slack/app/assets/fonts/lato-latin-700.woff2 +0 -0
  31. package/firedrill/tools/slack/app/assets/fonts/lato-latin-900.woff2 +0 -0
  32. package/firedrill/tools/slack/app/assets/slack-wordmark.svg +1 -0
  33. package/firedrill/tools/slack/app/assets/slack.svg +1 -0
  34. package/firedrill/tools/slack/app/site/app.js +2202 -0
  35. package/firedrill/tools/slack/app/site/assets/fonts/lato-latin-400.woff2 +0 -0
  36. package/firedrill/tools/slack/app/site/assets/fonts/lato-latin-700.woff2 +0 -0
  37. package/firedrill/tools/slack/app/site/assets/fonts/lato-latin-900.woff2 +0 -0
  38. package/firedrill/tools/slack/app/site/assets/slack-wordmark.svg +1 -0
  39. package/firedrill/tools/slack/app/site/assets/slack.svg +1 -0
  40. package/firedrill/tools/slack/app/site/chrome.css +73 -0
  41. package/firedrill/tools/slack/app/site/chrome.js +96 -0
  42. package/firedrill/tools/slack/app/site/icons.js +96 -0
  43. package/firedrill/tools/slack/app/site/index.html +253 -0
  44. package/firedrill/tools/slack/app/site/styles.css +2719 -0
  45. package/firedrill/tools/slack/app/site/ui.js +491 -0
  46. package/firedrill/tools/slack/behavior.mjs +1171 -0
  47. package/firedrill/tools/slack/lib/access.mjs +138 -0
  48. package/firedrill/tools/slack/lib/budget.mjs +67 -0
  49. package/firedrill/tools/slack/lib/ids.mjs +146 -0
  50. package/firedrill/tools/slack/lib/search.mjs +172 -0
  51. package/firedrill/tools/slack/lib/wire.mjs +144 -0
  52. package/firedrill/tools/slack/slack.tool.json +6114 -0
  53. package/firedrill/ts-sequence-full.scenario.json +38 -0
  54. package/firedrill/world.json +2008 -0
  55. package/firedrill.json +5 -0
  56. package/package.json +62 -0
  57. package/starter.json +1562 -0
  58. package/test/conformance.mjs +1166 -0
@@ -0,0 +1,491 @@
1
+ // DOM, Firedrill-client, formatting and interaction helpers for the Slack Tool app.
2
+ // Every record string reaches the page through textContent; nothing is injected as HTML.
3
+ import { getContext, invoke } from "/_firedrill/client.js";
4
+ import { icon } from "./icons.js";
5
+
6
+ export const $ = (selector, root = document) => root.querySelector(selector);
7
+ export const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
8
+
9
+ /** Create an element. `options`: { class, text, title, attrs: {…} }. Text always goes through textContent. */
10
+ export function el(tag, options = {}, children = []) {
11
+ const element = document.createElement(tag);
12
+ if (options.class) element.className = options.class;
13
+ if (options.text !== undefined) element.textContent = options.text;
14
+ if (options.title !== undefined) element.title = options.title;
15
+ if (options.attrs) for (const [name, value] of Object.entries(options.attrs)) if (value !== undefined && value !== null) element.setAttribute(name, String(value));
16
+ for (const child of Array.isArray(children) ? children : [children]) if (child !== undefined && child !== null && child !== false) element.append(child);
17
+ return element;
18
+ }
19
+
20
+ /** Square icon button with an accessible name; `options.tooltip === false` suppresses the title. */
21
+ export function iconButton(name, label, options = {}) {
22
+ const button = el("button", { class: `icon-btn ${options.class ?? ""}`.trim(), title: options.tooltip === false ? undefined : label, attrs: { type: "button", "aria-label": label } });
23
+ button.append(icon(name));
24
+ if (options.onClick) button.addEventListener("click", options.onClick);
25
+ return button;
26
+ }
27
+
28
+ export function textButton(text, options = {}) {
29
+ const button = el("button", { class: `btn ${options.class ?? ""}`.trim(), text, attrs: { type: "button", ...(options.attrs ?? {}) } });
30
+ if (options.onClick) button.addEventListener("click", options.onClick);
31
+ return button;
32
+ }
33
+
34
+ // ---------------------------------------------------------------------------------------------
35
+ // Tool calls
36
+ // ---------------------------------------------------------------------------------------------
37
+
38
+ export class ToolError extends Error {
39
+ constructor(status, code, message) {
40
+ super(message);
41
+ this.status = status;
42
+ this.code = code;
43
+ }
44
+ get denied() {
45
+ return this.status === "denied";
46
+ }
47
+ is(code) {
48
+ return this.code === `tool.${code}` || this.code === code;
49
+ }
50
+ }
51
+
52
+ /** Invoke one Tool operation; throws ToolError for every non-ok outcome. */
53
+ export async function call(operationId, args = {}, idempotencyKey) {
54
+ const result = await invoke(operationId, args, idempotencyKey ? { idempotencyKey } : {});
55
+ if (result.outcome.status !== "ok") {
56
+ const error = result.outcome.error ?? {};
57
+ throw new ToolError(result.outcome.status, error.code ?? result.outcome.status, error.message ?? `The operation was ${result.outcome.status}.`);
58
+ }
59
+ return result.outcome.value;
60
+ }
61
+
62
+ export const key = () => crypto.randomUUID();
63
+
64
+ const ERROR_TEXT = {
65
+ RATELIMITED: "Slack is rate-limiting posts right now (429). Nothing was sent — try again in 30 seconds.",
66
+ SERVICE_UNAVAILABLE: "History is temporarily unavailable (503). Try again in a moment.",
67
+ NOT_IN_CHANNEL: "You're not a member of this conversation.",
68
+ IS_ARCHIVED: "This channel is archived. It can be read but not changed.",
69
+ ALREADY_ARCHIVED: "This channel is already archived.",
70
+ CHANNEL_NOT_FOUND: "That conversation doesn't exist or isn't visible to you.",
71
+ THREAD_NOT_FOUND: "This thread no longer exists — the world may have been reset.",
72
+ MESSAGE_NOT_FOUND: "That message no longer exists.",
73
+ CANT_UPDATE_MESSAGE: "Only the author can edit a message.",
74
+ CANT_DELETE_MESSAGE: "Only the author or a workspace admin can delete this message.",
75
+ ALREADY_REACTED: "You already reacted with that emoji.",
76
+ NO_REACTION: "You haven't reacted with that emoji.",
77
+ TOO_MANY_REACTIONS: "This message already has the maximum number of different reactions (50).",
78
+ INVALID_NAME: "Channel names can only contain lowercase letters, numbers, hyphens, periods and underscores, and must be 80 characters or fewer.",
79
+ NAME_TAKEN: "That name is already taken by a channel or user in this workspace.",
80
+ ALREADY_IN_CHANNEL: "That person is already in the channel.",
81
+ USER_NOT_FOUND: "That person doesn't exist or has been deactivated.",
82
+ CANT_INVITE_SELF: "You can't invite yourself.",
83
+ METHOD_NOT_SUPPORTED_FOR_CHANNEL_TYPE: "That action isn't available for this kind of conversation.",
84
+ ALREADY_PINNED: "That message is already pinned.",
85
+ NOT_PINNED: "That message isn't pinned.",
86
+ NO_TEXT: "Type a message first.",
87
+ NO_QUERY: "Type something to search for.",
88
+ INVALID_CURSOR: "That page is no longer available. Reload the list.",
89
+ INVALID_AUTH: "This actor doesn't resolve to a workspace member (invalid_auth). Its userId attribute names no users row — fix it, or remove it to act as the workspace's first active member.",
90
+ };
91
+
92
+ export function describe(error) {
93
+ if (error instanceof ToolError) {
94
+ if (error.denied) return "You don't have permission to do that in this workspace (missing_scope).";
95
+ if (error.status === "unsupported") return "That operation isn't part of this Tool.";
96
+ if (error.is("INVALID_ARGUMENTS")) return `Slack rejected the request: ${error.message}`;
97
+ const code = error.code.replace(/^tool\./, "");
98
+ return Object.hasOwn(ERROR_TEXT, code) ? ERROR_TEXT[code] : error.message;
99
+ }
100
+ return error?.message ?? "Something went wrong. Reload and try again.";
101
+ }
102
+
103
+ // ---------------------------------------------------------------------------------------------
104
+ // One user action at a time
105
+ // ---------------------------------------------------------------------------------------------
106
+
107
+ let pending = 0;
108
+ export const isPending = () => pending > 0;
109
+
110
+ /** Run a user action; a second exclusive action is ignored while one is in flight so a write cannot be submitted twice. */
111
+ export async function action(task, { exclusive = true, onError } = {}) {
112
+ if (exclusive && pending > 0) return undefined;
113
+ pending += 1;
114
+ document.documentElement.toggleAttribute("data-busy", true);
115
+ try {
116
+ return await task();
117
+ } catch (error) {
118
+ if (onError) onError(error);
119
+ else toast(describe(error), { error: true });
120
+ return undefined;
121
+ } finally {
122
+ pending -= 1;
123
+ document.documentElement.toggleAttribute("data-busy", pending > 0);
124
+ }
125
+ }
126
+
127
+ // ---------------------------------------------------------------------------------------------
128
+ // Toast (bottom-centre notice, the way the client reports "Message sent"/errors)
129
+ // ---------------------------------------------------------------------------------------------
130
+
131
+ let toastTimer;
132
+ export function toast(text, { error = false, timeout = 5000 } = {}) {
133
+ const host = $("#toast");
134
+ $("#toast-text").textContent = text;
135
+ host.dataset.error = String(error);
136
+ host.hidden = false;
137
+ clearTimeout(toastTimer);
138
+ toastTimer = setTimeout(() => {
139
+ host.hidden = true;
140
+ }, timeout);
141
+ }
142
+
143
+ // ---------------------------------------------------------------------------------------------
144
+ // Popover menus
145
+ // ---------------------------------------------------------------------------------------------
146
+
147
+ let openMenuElement;
148
+ export function closeMenus() {
149
+ if (!openMenuElement) return;
150
+ const anchor = openMenuElement.anchor;
151
+ openMenuElement.remove();
152
+ openMenuElement = undefined;
153
+ if (anchor?.isConnected) {
154
+ anchor.setAttribute("aria-expanded", "false");
155
+ if (!document.activeElement || document.activeElement === document.body) anchor.focus();
156
+ }
157
+ }
158
+ export const menuOpen = () => Boolean(openMenuElement);
159
+
160
+ /**
161
+ * Open a menu near `anchor`. `content` is an array of items ({ label, icon?, danger?, disabled?, onSelect, detail? } or
162
+ * "divider") or a prebuilt element. Positioned below (or above) the anchor, aligned start/end, clamped to the window.
163
+ */
164
+ export function openMenu(anchor, content, { align = "start", className = "", header, above = false } = {}) {
165
+ closeMenus();
166
+ const menu = el("div", { class: `menu ${className}`.trim(), attrs: { role: "menu" } });
167
+ menu.anchor = anchor;
168
+ if (header) menu.append(el("div", { class: "menu-header", text: header }));
169
+ if (Array.isArray(content)) {
170
+ for (const item of content) {
171
+ if (item === "divider") {
172
+ menu.append(el("div", { class: "menu-divider", attrs: { role: "separator" } }));
173
+ continue;
174
+ }
175
+ const button = el("button", { class: `menu-item ${item.danger ? "danger" : ""}`.trim(), attrs: { type: "button", role: "menuitem" } });
176
+ if (item.icon) button.append(icon(item.icon, "menu-icon"));
177
+ button.append(el("span", { class: "menu-label", text: item.label }));
178
+ if (item.detail) button.append(el("span", { class: "menu-detail", text: item.detail }));
179
+ if (item.disabled) button.disabled = true;
180
+ button.addEventListener("click", () => {
181
+ closeMenus();
182
+ item.onSelect?.();
183
+ });
184
+ menu.append(button);
185
+ }
186
+ } else menu.append(content);
187
+ document.body.append(menu);
188
+ openMenuElement = menu;
189
+ anchor.setAttribute("aria-expanded", "true");
190
+ const rect = anchor.getBoundingClientRect();
191
+ const width = menu.offsetWidth;
192
+ const height = menu.offsetHeight;
193
+ let left = align === "end" ? rect.right - width : rect.left;
194
+ left = Math.max(8, Math.min(left, window.innerWidth - width - 8));
195
+ let top = above ? rect.top - height - 6 : rect.bottom + 6;
196
+ if (top + height > window.innerHeight - 8) top = Math.max(8, rect.top - height - 6);
197
+ if (top < 8) top = Math.min(rect.bottom + 6, window.innerHeight - height - 8);
198
+ menu.style.left = `${left}px`;
199
+ menu.style.top = `${top}px`;
200
+ menu.querySelector("input, button:not(:disabled)")?.focus();
201
+ return menu;
202
+ }
203
+
204
+ document.addEventListener("pointerdown", (event) => {
205
+ if (openMenuElement && !openMenuElement.contains(event.target) && !openMenuElement.anchor?.contains(event.target)) closeMenus();
206
+ });
207
+ document.addEventListener("keydown", (event) => {
208
+ if (!openMenuElement) return;
209
+ if (event.key === "Escape") {
210
+ event.preventDefault();
211
+ closeMenus();
212
+ } else if (event.key === "ArrowDown" || event.key === "ArrowUp") {
213
+ const items = $$(".menu-item:not(:disabled), .emoji-cell", openMenuElement);
214
+ if (items.length === 0) return;
215
+ const index = items.indexOf(document.activeElement);
216
+ const next = event.key === "ArrowDown" ? (index + 1) % items.length : (index - 1 + items.length) % items.length;
217
+ items[next].focus();
218
+ event.preventDefault();
219
+ }
220
+ });
221
+
222
+ // ---------------------------------------------------------------------------------------------
223
+ // Dialogs
224
+ // ---------------------------------------------------------------------------------------------
225
+
226
+ /** Slack-style confirmation modal; resolves true when confirmed. `preview` is an optional element shown above the text. */
227
+ export function confirmDialog(title, text, okLabel = "Confirm", { danger = false, preview } = {}) {
228
+ const dialog = $("#confirm-dialog");
229
+ $("#confirm-title").textContent = title;
230
+ $("#confirm-text").textContent = text;
231
+ const host = $("#confirm-preview");
232
+ host.replaceChildren();
233
+ host.hidden = !preview;
234
+ if (preview) host.append(preview);
235
+ const ok = $("#confirm-ok");
236
+ ok.textContent = okLabel;
237
+ ok.classList.toggle("danger", danger);
238
+ ok.classList.toggle("primary", !danger);
239
+ dialog.returnValue = "cancel";
240
+ dialog.showModal();
241
+ return new Promise((resolve) => dialog.addEventListener("close", () => resolve(dialog.returnValue === "ok"), { once: true }));
242
+ }
243
+
244
+ // ---------------------------------------------------------------------------------------------
245
+ // Time (virtual epoch seconds rendered in the acting member's timezone, never the browser clock)
246
+ // ---------------------------------------------------------------------------------------------
247
+
248
+ const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
249
+ const MONTHS_LONG = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
250
+ const WEEKDAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
251
+ const DAY_S = 86_400;
252
+
253
+ export const tsSeconds = (ts) => Number(String(ts).split(".")[0]);
254
+
255
+ /** Civil fields of a virtual instant shifted by the member's tz_offset (seconds). */
256
+ export function localParts(ts, tzOffset = 0) {
257
+ const shifted = new Date((tsSeconds(ts) + tzOffset) * 1000);
258
+ return {
259
+ year: shifted.getUTCFullYear(),
260
+ month: shifted.getUTCMonth(),
261
+ day: shifted.getUTCDate(),
262
+ weekday: shifted.getUTCDay(),
263
+ hours: shifted.getUTCHours(),
264
+ minutes: shifted.getUTCMinutes(),
265
+ dayIndex: Math.floor((tsSeconds(ts) + tzOffset) / DAY_S),
266
+ };
267
+ }
268
+
269
+ export function ordinal(day) {
270
+ const mod100 = day % 100;
271
+ if (mod100 >= 11 && mod100 <= 13) return `${day}th`;
272
+ const suffix = { 1: "st", 2: "nd", 3: "rd" }[day % 10] ?? "th";
273
+ return `${day}${suffix}`;
274
+ }
275
+
276
+ /** "9:30 AM" */
277
+ export function clockTime(ts, tzOffset = 0) {
278
+ const { hours, minutes } = localParts(ts, tzOffset);
279
+ const twelve = hours % 12 === 0 ? 12 : hours % 12;
280
+ return `${twelve}:${String(minutes).padStart(2, "0")} ${hours < 12 ? "AM" : "PM"}`;
281
+ }
282
+
283
+ /** Day-divider label the way the client shows it: Today, Yesterday, a weekday within the week, else "Mon, Sep 8th". */
284
+ export function dayLabel(ts, nowTs, tzOffset = 0) {
285
+ const parts = localParts(ts, tzOffset);
286
+ const today = localParts(nowTs, tzOffset);
287
+ const diff = today.dayIndex - parts.dayIndex;
288
+ if (diff === 0) return "Today";
289
+ if (diff === 1) return "Yesterday";
290
+ if (diff > 1 && diff < 7) return WEEKDAYS[parts.weekday];
291
+ const base = `${WEEKDAYS[parts.weekday].slice(0, 3)}, ${MONTHS[parts.month]} ${ordinal(parts.day)}`;
292
+ return parts.year === today.year ? base : `${MONTHS[parts.month]} ${ordinal(parts.day)}, ${parts.year}`;
293
+ }
294
+
295
+ /** Full timestamp for tooltips and search results: "Sep 8th, 2026 at 9:30 AM" */
296
+ export function fullDate(ts, tzOffset = 0) {
297
+ const parts = localParts(ts, tzOffset);
298
+ return `${MONTHS[parts.month]} ${ordinal(parts.day)}, ${parts.year} at ${clockTime(ts, tzOffset)}`;
299
+ }
300
+
301
+ /** "Created on September 8th, 2026" style date. */
302
+ export function longDate(ts, tzOffset = 0) {
303
+ const parts = localParts(ts, tzOffset);
304
+ return `${MONTHS_LONG[parts.month]} ${ordinal(parts.day)}, ${parts.year}`;
305
+ }
306
+
307
+ /** Relative label for "Last reply …": "just now", "5 minutes ago", "2 hours ago", "3 days ago", "1 month ago". */
308
+ export function relative(ts, nowTs) {
309
+ const diff = Math.max(0, tsSeconds(nowTs) - tsSeconds(ts));
310
+ const minutes = Math.round(diff / 60);
311
+ if (minutes < 1) return "just now";
312
+ if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;
313
+ const hours = Math.round(minutes / 60);
314
+ if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;
315
+ const days = Math.round(hours / 24);
316
+ if (days < 31) return `${days} day${days === 1 ? "" : "s"} ago`;
317
+ const months = Math.round(days / 30);
318
+ if (months < 12) return `${months} month${months === 1 ? "" : "s"} ago`;
319
+ const years = Math.round(days / 365);
320
+ return `${years} year${years === 1 ? "" : "s"} ago`;
321
+ }
322
+
323
+ /** Search-result timestamp: "9:30 AM" today, else "Sep 8th" / "Sep 8th, 2025". */
324
+ export function shortDate(ts, nowTs, tzOffset = 0) {
325
+ const parts = localParts(ts, tzOffset);
326
+ const today = localParts(nowTs, tzOffset);
327
+ if (parts.dayIndex === today.dayIndex) return clockTime(ts, tzOffset);
328
+ return parts.year === today.year ? `${MONTHS[parts.month]} ${ordinal(parts.day)}` : `${MONTHS[parts.month]} ${ordinal(parts.day)}, ${parts.year}`;
329
+ }
330
+
331
+ // ---------------------------------------------------------------------------------------------
332
+ // Emoji (Unicode glyphs from the viewer's system font; unknown names stay as :name: text)
333
+ // ---------------------------------------------------------------------------------------------
334
+
335
+ // A prototype-less map looked up with Object.hasOwn: message text chooses the shortcode, so `:__proto__:` or
336
+ // `:constructor:` must miss and stay literal text instead of resolving to inherited members.
337
+ export const EMOJI = Object.assign(Object.create(null), {
338
+ "+1": "👍", thumbsup: "👍", "-1": "👎", thumbsdown: "👎", heart: "❤️", tada: "🎉", eyes: "👀", rocket: "🚀",
339
+ white_check_mark: "✅", heavy_check_mark: "✔️", pray: "🙏", joy: "😂", fire: "🔥", wave: "👋", thinking_face: "🤔",
340
+ 100: "💯", palm_tree: "🌴", raised_hands: "🙌", clap: "👏", smile: "😄", grinning: "😀", slightly_smiling_face: "🙂",
341
+ sweat_smile: "😅", sob: "😭", cry: "😢", laughing: "😆", wink: "😉", blush: "😊", heart_eyes: "😍", sunglasses: "😎",
342
+ neutral_face: "😐", confused: "😕", scream: "😱", ok_hand: "👌", muscle: "💪", point_up: "☝️", handshake: "🤝",
343
+ bulb: "💡", warning: "⚠️", x: "❌", star: "⭐", sparkles: "✨", zap: "⚡", boom: "💥", coffee: "☕", pizza: "🍕",
344
+ beers: "🍻", cake: "🎂", bug: "🐛", ship: "🚢", package: "📦", memo: "📝", calendar: "📅", chart_with_upwards_trend: "📈",
345
+ hourglass: "⌛", alarm_clock: "⏰", lock: "🔒", key: "🔑", wrench: "🔧", hammer: "🔨", gear: "⚙️", rotating_light: "🚨",
346
+ white_circle: "⚪", large_green_circle: "🟢", red_circle: "🔴", spiral_calendar_pad: "🗓️", speech_balloon: "💬",
347
+ eyes_left: "👀", partying_face: "🥳", hugging_face: "🤗", face_with_monocle: "🧐", saluting_face: "🫡", heavy_plus_sign: "➕",
348
+ });
349
+ /** The glyph for a shortcode, or undefined when the name is not a known emoji (never an inherited member). */
350
+ export const emojiGlyph = (name) => (Object.hasOwn(EMOJI, name) ? EMOJI[name] : undefined);
351
+ export const QUICK_REACTIONS = ["white_check_mark", "eyes", "raised_hands"];
352
+ export const PICKER_EMOJI = [
353
+ "+1", "heart", "tada", "eyes", "rocket", "white_check_mark", "pray", "joy", "fire", "wave", "thinking_face", "100",
354
+ "raised_hands", "clap", "smile", "sweat_smile", "sob", "laughing", "ok_hand", "muscle", "bulb", "warning", "x", "star",
355
+ "sparkles", "zap", "coffee", "pizza", "beers", "cake", "bug", "ship", "package", "memo", "calendar", "rotating_light",
356
+ "partying_face", "saluting_face", "heavy_check_mark", "speech_balloon",
357
+ ];
358
+ export const emoji = (name) => emojiGlyph(name) ?? `:${name}:`;
359
+ export const emojiLabel = (name) => name.replaceAll("_", " ");
360
+
361
+ // ---------------------------------------------------------------------------------------------
362
+ // Message text → safe DOM (mrkdwn subset: *bold* _italic_ ~strike~ `code` ```pre```, <@U…>, <#C…|name>, <url|label>, :emoji:)
363
+ // ---------------------------------------------------------------------------------------------
364
+
365
+ const TOKEN = /```([\s\S]*?)```|`([^`\n]+)`|<@([A-Z0-9]+)(?:\|[^>]*)?>|<#([A-Z0-9]+)(?:\|([^>]*))?>|<(https?:\/\/[^|>]+)(?:\|([^>]*))?>|<!([a-z]+)(?:\|[^>]*)?>|(https?:\/\/[^\s<>]+)|:([a-z0-9_+-]+):/g;
366
+ const STYLE = /(?<![\w*])\*([^*\n]+)\*(?![\w*])|(?<![\w_])_([^_\n]+)_(?![\w_])|(?<![\w~])~([^~\n]+)~(?![\w~])/g;
367
+
368
+ /**
369
+ * Render message text into `target` using text nodes and styled spans only. `resolve` maps user ids to handles and
370
+ * channel ids to names; mention pills call `onMention(userId)` / `onChannel(channelId)` when clicked.
371
+ */
372
+ export function renderText(target, text, { resolve, onMention, onChannel } = {}) {
373
+ target.replaceChildren();
374
+ const source = String(text ?? "");
375
+ let last = 0;
376
+ for (const match of source.matchAll(TOKEN)) {
377
+ if (match.index > last) appendStyled(target, source.slice(last, match.index));
378
+ const [whole, pre, code, userId, channelId, channelName, url, urlLabel, special, bareUrl, shortcode] = match;
379
+ if (pre !== undefined) target.append(el("pre", { class: "msg-pre", text: pre.replace(/^\n/, "").replace(/\n$/, "") }));
380
+ else if (code !== undefined) target.append(el("code", { class: "msg-code", text: code }));
381
+ else if (userId !== undefined) {
382
+ const handle = resolve?.user?.(userId);
383
+ const pill = el("button", { class: "mention", text: `@${handle ?? userId}`, attrs: { type: "button" } });
384
+ if (onMention) pill.addEventListener("click", () => onMention(userId));
385
+ target.append(pill);
386
+ } else if (channelId !== undefined) {
387
+ const name = channelName || resolve?.channel?.(channelId) || channelId;
388
+ const pill = el("button", { class: "channel-link", text: `#${name}`, attrs: { type: "button" } });
389
+ if (onChannel) pill.addEventListener("click", () => onChannel(channelId));
390
+ target.append(pill);
391
+ } else if (url !== undefined) target.append(el("span", { class: "msg-link", text: urlLabel || url, title: url }));
392
+ else if (special !== undefined) target.append(el("span", { class: "mention", text: `@${special}` }));
393
+ else if (bareUrl !== undefined) target.append(el("span", { class: "msg-link", text: bareUrl, title: bareUrl }));
394
+ else if (shortcode !== undefined) target.append(emojiGlyph(shortcode) === undefined ? whole : el("span", { class: "msg-emoji", text: emojiGlyph(shortcode), title: `:${shortcode}:` }));
395
+ last = match.index + whole.length;
396
+ }
397
+ if (last < source.length) appendStyled(target, source.slice(last));
398
+ }
399
+
400
+ function appendStyled(target, text) {
401
+ let last = 0;
402
+ for (const match of text.matchAll(STYLE)) {
403
+ if (match.index > last) target.append(text.slice(last, match.index));
404
+ const [whole, bold, italic, strike] = match;
405
+ if (bold !== undefined) target.append(el("strong", { text: bold }));
406
+ else if (italic !== undefined) target.append(el("em", { text: italic }));
407
+ else target.append(el("s", { text: strike }));
408
+ last = match.index + whole.length;
409
+ }
410
+ if (last < text.length) target.append(text.slice(last));
411
+ }
412
+
413
+ /** Plain one-line preview of a message (mentions resolved, shortcodes kept), for sidebar/search/dialog previews. */
414
+ export function plainText(text, resolve) {
415
+ return String(text ?? "")
416
+ .replace(/<@([A-Z0-9]+)(?:\|[^>]*)?>/g, (_, id) => `@${resolve?.user?.(id) ?? id}`)
417
+ .replace(/<#([A-Z0-9]+)(?:\|([^>]*))?>/g, (_, id, name) => `#${name || resolve?.channel?.(id) || id}`)
418
+ .replace(/<(https?:\/\/[^|>]+)(?:\|([^>]*))?>/g, (_, url, label) => label || url)
419
+ .replace(/:([a-z0-9_+-]+):/g, (whole, name) => emojiGlyph(name) ?? whole)
420
+ .replace(/\s+/g, " ")
421
+ .trim();
422
+ }
423
+
424
+ // ---------------------------------------------------------------------------------------------
425
+ // Avatars: rounded-square initials on a per-user deterministic colour (the client's placeholder avatars)
426
+ // ---------------------------------------------------------------------------------------------
427
+
428
+ const AVATAR_COLORS = ["#4A154B", "#E01E5A", "#36C5F0", "#2EB67D", "#ECB22E", "#1264A3", "#7C3085", "#DE4E2B"];
429
+ export function avatarColor(seed) {
430
+ let hash = 0;
431
+ for (const character of String(seed)) hash = (hash * 31 + character.charCodeAt(0)) >>> 0;
432
+ return AVATAR_COLORS[hash % AVATAR_COLORS.length];
433
+ }
434
+
435
+ /** Letter avatar; `size` is a class (sm 20 px, md 24 px, lg 36 px, xl 72 px, xxl 192 px). */
436
+ export function avatar(user, size = "lg") {
437
+ const name = user?.real_name || user?.name || user?.id || "?";
438
+ const element = el("span", { class: `avatar ${size}`, text: name.trim().charAt(0).toUpperCase(), attrs: { "aria-hidden": "true" } });
439
+ element.style.background = avatarColor(user?.id ?? name);
440
+ return element;
441
+ }
442
+
443
+ // ---------------------------------------------------------------------------------------------
444
+ // Per-viewer conveniences (never authority over records)
445
+ // ---------------------------------------------------------------------------------------------
446
+
447
+ export function readPreference(name, fallback) {
448
+ try {
449
+ return sessionStorage.getItem(`slack-tool:${name}`) ?? fallback;
450
+ } catch {
451
+ return fallback;
452
+ }
453
+ }
454
+ export function writePreference(name, value) {
455
+ try {
456
+ sessionStorage.setItem(`slack-tool:${name}`, value);
457
+ } catch {
458
+ /* storage blocked: the convenience simply does not persist */
459
+ }
460
+ }
461
+
462
+ // ---------------------------------------------------------------------------------------------
463
+ // World revision polling
464
+ // ---------------------------------------------------------------------------------------------
465
+
466
+ /** Poll the world revision and refresh when something changed (agent activity, reset, other tabs). */
467
+ export function watchWorld(refresh, mayRefresh = () => true, onContext) {
468
+ let revision;
469
+ let checking = false;
470
+ const check = async () => {
471
+ if (checking || pending > 0 || document.visibilityState !== "visible") return;
472
+ checking = true;
473
+ try {
474
+ const context = await getContext();
475
+ onContext?.(context);
476
+ const stamp = JSON.stringify(context.revision);
477
+ if (revision !== stamp) {
478
+ const first = revision === undefined;
479
+ revision = stamp;
480
+ if (first || mayRefresh()) await refresh(first);
481
+ }
482
+ } catch (error) {
483
+ toast(error?.message ?? "The local environment is unavailable.", { error: true });
484
+ } finally {
485
+ checking = false;
486
+ }
487
+ };
488
+ const timer = setInterval(() => void check(), 2000);
489
+ window.addEventListener("pagehide", () => clearInterval(timer), { once: true });
490
+ void check();
491
+ }