@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,1171 @@
1
+ // Synthetic Slack workspace. Every operation computes from context.state; ids and `ts` values come from the
2
+ // workspace counters, times from the virtual clock. Nothing leaves the world and no Slack service is contacted.
3
+ import {
4
+ allRows,
5
+ fail,
6
+ isMember,
7
+ isPublicChannel,
8
+ limitsOf,
9
+ prefixRows,
10
+ requireActive,
11
+ requireActor,
12
+ requireMember,
13
+ requireWithinBound,
14
+ visibleConversation,
15
+ } from "./lib/access.mjs";
16
+ import {
17
+ MAX_TS_SEQUENCE,
18
+ compareTs,
19
+ conversationId,
20
+ decodeCursor,
21
+ encodeCursor,
22
+ isTs,
23
+ isTsLike,
24
+ membershipRowId,
25
+ messageRowId,
26
+ permalink,
27
+ pinRowId,
28
+ renderTs,
29
+ tsNumber,
30
+ } from "./lib/ids.mjs";
31
+ import { RESPONSE_BYTES, fitCount, jsonBytes, uniformPageSize } from "./lib/budget.mjs";
32
+ import { SearchError, parseQuery, scoreMessage } from "./lib/search.mjs";
33
+ import { bool, defined, int, operationInput, params, slackError, str } from "./lib/wire.mjs";
34
+
35
+ const MAX_LIST = 1000;
36
+ const MAX_HISTORY = 999;
37
+ const MAX_SEARCH = 100;
38
+ const MAX_DISTINCT_REACTIONS = 50;
39
+ const MAX_OPEN_USERS = 8;
40
+ const CHANNEL_NAME = /^[a-z0-9._-]{1,80}$/;
41
+ const REACTION_NAME = /^[a-z0-9_+-]{1,100}$/;
42
+ const ALL_TYPES = ["public_channel", "private_channel", "mpim", "im"];
43
+
44
+ // ---------------------------------------------------------------------------------------------
45
+ // Workspace row, clock and counters
46
+ // ---------------------------------------------------------------------------------------------
47
+
48
+ function workspace(context) {
49
+ const stored = context.state.get("workspace", "team");
50
+ if (stored !== null) return { ...stored };
51
+ return {
52
+ id: "T00000000",
53
+ name: "Workspace",
54
+ domain: "example-team",
55
+ url: "https://example-team.slack.com/",
56
+ id_sequence: 0,
57
+ message_sequence: 0,
58
+ };
59
+ }
60
+
61
+ function saveWorkspace(context, team) {
62
+ context.state.put("workspace", "team", team);
63
+ }
64
+
65
+ function nowSeconds(context) {
66
+ return Math.floor(context.clock.nowUs() / 1_000_000);
67
+ }
68
+
69
+ /**
70
+ * Allocate the next message ts: the virtual second plus a six-digit sequence that restarts every virtual second
71
+ * (`workspace.message_second` remembers the second the sequence belongs to). A second can hold at most
72
+ * MAX_TS_SEQUENCE timestamps; the next allocation in the same second fails with TOO_MANY_ROWS before any write.
73
+ */
74
+ function nextTs(context, team) {
75
+ const seconds = nowSeconds(context);
76
+ if (team.message_second !== seconds) {
77
+ team.message_second = seconds;
78
+ team.message_sequence = 0;
79
+ }
80
+ if (team.message_sequence >= MAX_TS_SEQUENCE) {
81
+ return fail(
82
+ context,
83
+ "TOO_MANY_ROWS",
84
+ `message ts limit reached: virtual second ${seconds} already holds ${MAX_TS_SEQUENCE} message timestamps, the most a six-digit ts fraction can encode; advance the world clock`,
85
+ );
86
+ }
87
+ team.message_sequence += 1;
88
+ return renderTs(seconds, team.message_sequence);
89
+ }
90
+
91
+ function nextConversationId(team, prefix) {
92
+ team.id_sequence += 1;
93
+ return conversationId(prefix, team.id_sequence);
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------------------------
97
+ // Argument helpers (the reference MCP server spells some arguments differently from the Web API)
98
+ // ---------------------------------------------------------------------------------------------
99
+
100
+ function invalid(context, message) {
101
+ return fail(context, "INVALID_ARGUMENTS", message);
102
+ }
103
+
104
+ function exactlyOne(context, input, first, second, required = true) {
105
+ const hasFirst = input[first] !== undefined;
106
+ const hasSecond = input[second] !== undefined;
107
+ if (hasFirst && hasSecond) return invalid(context, `exactly one of ${first} or ${second} is required`);
108
+ if (!hasFirst && !hasSecond) {
109
+ return required ? invalid(context, `exactly one of ${first} or ${second} is required`) : undefined;
110
+ }
111
+ return hasFirst ? input[first] : input[second];
112
+ }
113
+
114
+ function channelArg(context, input) {
115
+ return exactlyOne(context, input, "channel", "channel_id");
116
+ }
117
+
118
+ function listLimit(context, input, maximum, fallback) {
119
+ const limit = input.limit;
120
+ if (limit === undefined) return fallback ?? maximum;
121
+ if (limit < 1) return invalid(context, "invalid_limit");
122
+ return Math.min(limit, maximum);
123
+ }
124
+
125
+ function commaList(value) {
126
+ return String(value)
127
+ .split(",")
128
+ .map((item) => item.trim())
129
+ .filter((item) => item.length > 0);
130
+ }
131
+
132
+ // ---------------------------------------------------------------------------------------------
133
+ // Rows and views
134
+ // ---------------------------------------------------------------------------------------------
135
+
136
+ function usersById(context) {
137
+ const map = new Map();
138
+ for (const user of allRows(context, "users")) map.set(user.id, user);
139
+ return map;
140
+ }
141
+
142
+ function memberIds(context, channelId) {
143
+ return prefixRows(context, "memberships", `${channelId}:`)
144
+ .map((row) => row.user)
145
+ .sort();
146
+ }
147
+
148
+ /** Conversations the actor belongs to, in id order (bounded: ≤ 1,000 conversations, one probe each). */
149
+ function conversationsOf(context, actorId) {
150
+ return allRows(context, "conversations")
151
+ .filter((conversation) => isMember(context, conversation.id, actorId))
152
+ .sort((left, right) => (left.id < right.id ? -1 : left.id > right.id ? 1 : 0));
153
+ }
154
+
155
+ function channelMessages(context, channelId) {
156
+ return prefixRows(context, "messages", `${channelId}:`).sort((left, right) => compareTs(left.ts, right.ts));
157
+ }
158
+
159
+ function isTopLevel(message) {
160
+ return message.thread_ts === undefined || message.thread_ts === message.ts || message.subtype === "thread_broadcast";
161
+ }
162
+
163
+ function isParent(message) {
164
+ return message.thread_ts === undefined || message.thread_ts === message.ts;
165
+ }
166
+
167
+ function userView(user) {
168
+ return {
169
+ id: user.id,
170
+ team_id: user.team_id,
171
+ name: user.name,
172
+ deleted: user.deleted,
173
+ real_name: user.real_name,
174
+ tz: user.tz,
175
+ tz_label: user.tz_label,
176
+ tz_offset: user.tz_offset,
177
+ profile: { ...user.profile },
178
+ is_admin: user.is_admin,
179
+ is_owner: user.is_owner,
180
+ is_bot: user.is_bot,
181
+ is_app_user: false,
182
+ is_email_confirmed: true,
183
+ updated: user.updated,
184
+ ...(user.bot_id === undefined ? {} : { bot_id: user.bot_id }),
185
+ };
186
+ }
187
+
188
+ function conversationView(context, conversation, actorId, withCount) {
189
+ const view = { ...conversation, is_member: isMember(context, conversation.id, actorId) };
190
+ if (conversation.is_im === true) {
191
+ const partner = memberIds(context, conversation.id).find((id) => id !== actorId);
192
+ view.user = partner ?? conversation.user;
193
+ }
194
+ if (withCount) view.num_members = memberIds(context, conversation.id).length;
195
+ return view;
196
+ }
197
+
198
+ /**
199
+ * The view of a conversation this operation has just created, built from the rows it wrote (the actor is a member and
200
+ * `memberCount` memberships were stored). No state scan runs after an id has been allocated, so no failure raised after
201
+ * the allocation can name that id: the failure would roll the conversation back and the id would be reassigned.
202
+ */
203
+ const MAX_CONVERSATION_NAME = 80;
204
+
205
+ // Group DM names follow Slack's `mpdm-<handle>--<handle>-1`. Handles may be 21 characters and a group DM may hold nine
206
+ // members, so the plain form can exceed the 80-character conversation name. Longer names keep a prefix of the plain
207
+ // form and end with an 8-hex FNV-1a digest of the full handle list, so the name stays within 80 characters, remains a
208
+ // valid channel name, and different member sets keep distinct names.
209
+ function groupDmName(sortedHandles) {
210
+ const joined = sortedHandles.join("--");
211
+ const plain = `mpdm-${joined}-1`;
212
+ if (plain.length <= MAX_CONVERSATION_NAME) return plain;
213
+ let hash = 0x811c9dc5;
214
+ for (let index = 0; index < joined.length; index += 1) {
215
+ hash ^= joined.charCodeAt(index);
216
+ hash = Math.imul(hash, 0x01000193) >>> 0;
217
+ }
218
+ const digest = hash.toString(16).padStart(8, "0");
219
+ const prefix = plain.slice(0, MAX_CONVERSATION_NAME - digest.length - 3).replace(/[-.]+$/, "");
220
+ return `${prefix}-${digest}-1`;
221
+ }
222
+
223
+ function createdConversationView(conversation, memberCount) {
224
+ return { ...conversation, is_member: true, num_members: memberCount };
225
+ }
226
+
227
+ function messageView(message, withChannel) {
228
+ const view = { ...message };
229
+ if (!withChannel) {
230
+ delete view.channel;
231
+ delete view.permalink;
232
+ }
233
+ if (Array.isArray(view.reactions) && view.reactions.length === 0) delete view.reactions;
234
+ return view;
235
+ }
236
+
237
+ function findMessage(context, channelId, ts) {
238
+ if (typeof ts !== "string" || !isTs(ts)) return null;
239
+ const row = context.state.get("messages", messageRowId(channelId, ts));
240
+ return row !== null && row.channel === channelId ? row : null;
241
+ }
242
+
243
+ function requireMessage(context, channelId, ts) {
244
+ return findMessage(context, channelId, ts) ?? fail(context, "MESSAGE_NOT_FOUND");
245
+ }
246
+
247
+ /** Open a conversation for the actor with the checks a bot token needs: visible, then member, then not archived. */
248
+ function openConversation(context, actor, channelId, options = {}) {
249
+ const conversation = visibleConversation(context, actor.id, channelId);
250
+ if (options.member) requireMember(context, conversation, actor.id);
251
+ if (options.active) requireActive(context, conversation);
252
+ return conversation;
253
+ }
254
+
255
+ // ---------------------------------------------------------------------------------------------
256
+ // Writes shared by several operations
257
+ // ---------------------------------------------------------------------------------------------
258
+
259
+ function newMessage(context, team, conversation, fields) {
260
+ // Authored rows may already occupy a ts in the current second; skip past them so a ts is never reused.
261
+ let ts = nextTs(context, team);
262
+ while (context.state.get("messages", messageRowId(conversation.id, ts)) !== null) ts = nextTs(context, team);
263
+ const message = {
264
+ channel: conversation.id,
265
+ ts,
266
+ type: "message",
267
+ ...fields,
268
+ reactions: [],
269
+ permalink: permalink(team.url, conversation.id, ts),
270
+ team: team.id,
271
+ };
272
+ context.state.put("messages", messageRowId(conversation.id, ts), message);
273
+ return message;
274
+ }
275
+
276
+ function joinMessage(context, team, conversation, user, invitedBy) {
277
+ return newMessage(context, team, conversation, {
278
+ subtype: "channel_join",
279
+ user: user.id,
280
+ text: invitedBy === undefined ? `<@${user.id}> has joined the channel` : `<@${user.id}> has joined the channel by invitation from <@${invitedBy}>`,
281
+ });
282
+ }
283
+
284
+ function addMembership(context, conversation, userId, invitedBy) {
285
+ context.state.put(
286
+ "memberships",
287
+ membershipRowId(conversation.id, userId),
288
+ defined({ channel: conversation.id, user: userId, joined: nowSeconds(context), invited_by: invitedBy }),
289
+ );
290
+ }
291
+
292
+ /** reply_users keeps at most this many distinct repliers (the messages schema bound); reply_users_count counts all. */
293
+ const MAX_REPLY_USERS = 50;
294
+
295
+ /** Block Kit bounds this workspace accepts: Slack's 50 blocks, plus a nesting depth and encoded-size cap per message. */
296
+ const MAX_BLOCKS = 50;
297
+ const MAX_BLOCK_DEPTH = 32;
298
+ const MAX_BLOCKS_BYTES = 100000;
299
+
300
+ /**
301
+ * `blocks` arrive as a JSON string on the Web API (the form field the SDKs send) or as an array on canonical and MCP
302
+ * calls. A string that is not JSON fails with invalid_blocks_format; JSON that is not an array of at most 50 objects each
303
+ * carrying a string `type`, nests deeper than 32 levels or encodes to more than 100 KB fails with invalid_blocks.
304
+ * Nesting is measured with an explicit stack, never recursion.
305
+ */
306
+ function blocksArg(context, value) {
307
+ if (value === undefined) return undefined;
308
+ let blocks = value;
309
+ if (typeof value === "string") {
310
+ try {
311
+ blocks = JSON.parse(value);
312
+ } catch {
313
+ return fail(context, "INVALID_BLOCKS_FORMAT", "blocks must be a JSON-encoded array of layout blocks");
314
+ }
315
+ }
316
+ const invalidBlocks = (reason) => fail(context, "INVALID_BLOCKS", reason);
317
+ if (!Array.isArray(blocks)) return invalidBlocks("blocks must be an array");
318
+ if (blocks.length > MAX_BLOCKS) return invalidBlocks(`no more than ${MAX_BLOCKS} blocks are allowed`);
319
+ for (const block of blocks) {
320
+ if (block === null || typeof block !== "object" || Array.isArray(block)) return invalidBlocks("every block must be an object");
321
+ if (typeof block.type !== "string" || block.type.length === 0 || block.type.length > 255) return invalidBlocks("every block needs a string type");
322
+ }
323
+ const stack = [[blocks, 1]];
324
+ while (stack.length > 0) {
325
+ const [node, depth] = stack.pop();
326
+ if (depth > MAX_BLOCK_DEPTH) return invalidBlocks(`blocks nest deeper than ${MAX_BLOCK_DEPTH} levels`);
327
+ for (const child of Array.isArray(node) ? node : Object.values(node)) {
328
+ if (child !== null && typeof child === "object") stack.push([child, depth + 1]);
329
+ }
330
+ }
331
+ if (jsonBytes(blocks) > MAX_BLOCKS_BYTES) return invalidBlocks(`blocks encode to more than ${MAX_BLOCKS_BYTES} bytes`);
332
+ return blocks;
333
+ }
334
+
335
+ function postMessage(context, actor, input, threadRequired) {
336
+ const channelId = channelArg(context, input);
337
+ const conversation = openConversation(context, actor, channelId, { member: true, active: true });
338
+ const text = typeof input.text === "string" ? input.text : "";
339
+ const blocks = blocksArg(context, input.blocks);
340
+ if (text.trim().length === 0 && (blocks === undefined || blocks.length === 0)) return fail(context, "NO_TEXT");
341
+ const threadTs = input.thread_ts;
342
+ if (threadRequired && threadTs === undefined) return invalid(context, "thread_ts is required");
343
+ let parent;
344
+ if (threadTs !== undefined) {
345
+ parent = findMessage(context, conversation.id, threadTs);
346
+ if (parent === null || !isParent(parent) || parent.subtype === "tombstone") {
347
+ return fail(context, "THREAD_NOT_FOUND", "cannot_reply_to_message");
348
+ }
349
+ }
350
+ let threadRepliers;
351
+ if (parent !== undefined) {
352
+ // Keep the first MAX_REPLY_USERS distinct repliers in reply_users (the stored schema's bound) while
353
+ // reply_users_count keeps counting every distinct replier. Decide whether this actor is new before any id is
354
+ // allocated: when the list is already full and does not name the actor, look for an earlier reply of theirs.
355
+ const listed = Array.isArray(parent.reply_users) ? [...parent.reply_users] : [];
356
+ const count = Math.max(parent.reply_users_count ?? 0, listed.length);
357
+ let repeat = listed.includes(actor.id);
358
+ if (!repeat && listed.length >= MAX_REPLY_USERS) {
359
+ repeat = channelMessages(context, conversation.id).some(
360
+ (row) => row.thread_ts === parent.ts && row.ts !== parent.ts && row.user === actor.id,
361
+ );
362
+ }
363
+ threadRepliers = {
364
+ reply_users: repeat || listed.length >= MAX_REPLY_USERS ? listed : [...listed, actor.id],
365
+ reply_users_count: repeat ? count : count + 1,
366
+ };
367
+ }
368
+ const team = workspace(context);
369
+ const fields = defined({
370
+ user: actor.id,
371
+ text,
372
+ blocks,
373
+ ...(actor.is_bot === true ? { subtype: "bot_message", bot_id: actor.bot_id } : {}),
374
+ });
375
+ if (parent !== undefined) {
376
+ fields.thread_ts = parent.ts;
377
+ if (input.reply_broadcast === true) fields.subtype = "thread_broadcast";
378
+ }
379
+ const message = newMessage(context, team, conversation, fields);
380
+ if (parent !== undefined) {
381
+ context.state.put(
382
+ "messages",
383
+ messageRowId(conversation.id, parent.ts),
384
+ {
385
+ ...parent,
386
+ thread_ts: parent.ts,
387
+ reply_count: (parent.reply_count ?? 0) + 1,
388
+ ...threadRepliers,
389
+ latest_reply: message.ts,
390
+ },
391
+ );
392
+ }
393
+ saveWorkspace(context, team);
394
+ context.events.emit(
395
+ "message.posted",
396
+ defined({ channel: conversation.id, ts: message.ts, user: actor.id, thread_ts: message.thread_ts, subtype: message.subtype, text }),
397
+ );
398
+ return { ok: true, channel: conversation.id, ts: message.ts, message: messageView(message, false) };
399
+ }
400
+
401
+ /** Recompute a parent's thread counters after one of its replies was deleted. */
402
+ function refreshParent(context, channelId, parentTs) {
403
+ const parent = findMessage(context, channelId, parentTs);
404
+ if (parent === null) return;
405
+ const replies = channelMessages(context, channelId).filter((row) => row.thread_ts === parentTs && row.ts !== parentTs);
406
+ if (replies.length === 0) {
407
+ if (parent.subtype === "tombstone") {
408
+ context.state.delete("messages", messageRowId(channelId, parentTs));
409
+ return;
410
+ }
411
+ const cleared = { ...parent };
412
+ delete cleared.thread_ts;
413
+ delete cleared.reply_count;
414
+ delete cleared.reply_users;
415
+ delete cleared.reply_users_count;
416
+ delete cleared.latest_reply;
417
+ context.state.put("messages", messageRowId(channelId, parentTs), cleared);
418
+ return;
419
+ }
420
+ const replyUsers = [];
421
+ for (const reply of replies) if (!replyUsers.includes(reply.user)) replyUsers.push(reply.user);
422
+ context.state.put("messages", messageRowId(channelId, parentTs), {
423
+ ...parent,
424
+ thread_ts: parentTs,
425
+ reply_count: replies.length,
426
+ reply_users: replyUsers.slice(0, MAX_REPLY_USERS),
427
+ reply_users_count: replyUsers.length,
428
+ latest_reply: replies[replies.length - 1].ts,
429
+ });
430
+ }
431
+
432
+ function unpin(context, channelId, ts) {
433
+ context.state.delete("pins", pinRowId(channelId, ts));
434
+ const message = findMessage(context, channelId, ts);
435
+ if (message !== null && Array.isArray(message.pinned_to)) {
436
+ const rest = { ...message };
437
+ delete rest.pinned_to;
438
+ context.state.put("messages", messageRowId(channelId, ts), rest);
439
+ }
440
+ }
441
+
442
+ // ---------------------------------------------------------------------------------------------
443
+ // Pagination helpers
444
+ // ---------------------------------------------------------------------------------------------
445
+
446
+ /** Bytes reserved in an envelope for a next_cursor not yet known when the page is sized. */
447
+ const CURSOR_RESERVE = 256;
448
+
449
+ /** The response byte budget: 900 KB, or workspace.limits.response_bytes when the workspace row sets a lower one. */
450
+ function responseBudget(context) {
451
+ const configured = limitsOf(context).response_bytes;
452
+ return Number.isInteger(configured) ? Math.min(configured, RESPONSE_BYTES) : RESPONSE_BYTES;
453
+ }
454
+
455
+ /**
456
+ * How many leading `candidates` (already views) fit the response budget next to `envelope` (the body with an empty
457
+ * list). A first candidate that alone exceeds the budget fails with `code` and a message naming the budget, never an
458
+ * empty or oversized page.
459
+ */
460
+ function fitPage(context, candidates, envelope, code, subject) {
461
+ const budget = responseBudget(context);
462
+ const count = fitCount(candidates, candidates.length, jsonBytes(envelope) + CURSOR_RESERVE, budget, jsonBytes);
463
+ if (count === 0 && candidates.length > 0) {
464
+ return fail(context, code, `${subject} exceeds the response budget of ${budget} bytes (workspace.limits.response_bytes)`);
465
+ }
466
+ return count;
467
+ }
468
+
469
+ function paginateIds(context, items, cursor, kind, limit, idOf, fit) {
470
+ let start = 0;
471
+ if (cursor !== undefined) {
472
+ const anchor = decodeCursor(cursor, kind);
473
+ const index = anchor === null ? -1 : items.findIndex((item) => idOf(item) === anchor);
474
+ if (index < 0) return fail(context, "INVALID_CURSOR");
475
+ start = index + 1;
476
+ }
477
+ const candidates = items.slice(start, start + limit);
478
+ const page = fit === undefined ? candidates : candidates.slice(0, fit(candidates));
479
+ const last = page[page.length - 1];
480
+ const nextCursor = start + page.length < items.length && last !== undefined ? encodeCursor(kind, idOf(last)) : "";
481
+ return { page, nextCursor };
482
+ }
483
+
484
+ function tsWindow(context, messages, input) {
485
+ let rows = messages;
486
+ const inclusive = input.inclusive === true;
487
+ if (input.oldest !== undefined) {
488
+ if (!isTsLike(input.oldest)) return invalid(context, "invalid_ts_oldest");
489
+ const oldest = tsNumber(input.oldest);
490
+ rows = rows.filter((row) => (inclusive ? tsNumber(row.ts) >= oldest : tsNumber(row.ts) > oldest));
491
+ }
492
+ if (input.latest !== undefined) {
493
+ if (!isTsLike(input.latest)) return invalid(context, "invalid_ts_latest");
494
+ const latest = tsNumber(input.latest);
495
+ rows = rows.filter((row) => (inclusive ? tsNumber(row.ts) <= latest : tsNumber(row.ts) < latest));
496
+ }
497
+ return rows;
498
+ }
499
+
500
+ // ---------------------------------------------------------------------------------------------
501
+ // Operations
502
+ // ---------------------------------------------------------------------------------------------
503
+
504
+ const operations = {
505
+ "auth.test": (input, context) => {
506
+ const actor = requireActor(context);
507
+ const team = workspace(context);
508
+ return defined({
509
+ ok: true,
510
+ url: team.url,
511
+ team: team.name,
512
+ user: actor.name,
513
+ team_id: team.id,
514
+ user_id: actor.id,
515
+ bot_id: actor.bot_id,
516
+ is_enterprise_install: false,
517
+ });
518
+ },
519
+
520
+ "users.list": (input, context) => {
521
+ requireActor(context);
522
+ const limit = listLimit(context, input, MAX_LIST);
523
+ const users = allRows(context, "users").sort((left, right) => (left.id < right.id ? -1 : 1));
524
+ const envelope = { ok: true, members: [], cache_ts: nowSeconds(context), response_metadata: { next_cursor: "" } };
525
+ const fit = (candidates) => fitPage(context, candidates.map(userView), envelope, "TOO_MANY_ROWS", "one user");
526
+ const { page, nextCursor } = paginateIds(context, users, input.cursor, "user", limit, (user) => user.id, fit);
527
+ return { ...envelope, members: page.map(userView), response_metadata: { next_cursor: nextCursor } };
528
+ },
529
+
530
+ "users.info": (input, context) => {
531
+ requireActor(context);
532
+ const userId = exactlyOne(context, input, "user", "user_id");
533
+ const user = context.state.get("users", userId);
534
+ if (user === null) return fail(context, "USER_NOT_FOUND");
535
+ return { ok: true, user: userView(user) };
536
+ },
537
+
538
+ "users.profile.get": (input, context) => {
539
+ const actor = requireActor(context);
540
+ const userId = exactlyOne(context, input, "user", "user_id", false) ?? actor.id;
541
+ const user = context.state.get("users", userId);
542
+ if (user === null) return fail(context, "USER_NOT_FOUND");
543
+ return { ok: true, profile: { ...user.profile, avatar_hash: "" } };
544
+ },
545
+
546
+ "conversations.list": (input, context) => {
547
+ const actor = requireActor(context);
548
+ const types = input.types === undefined ? ["public_channel"] : commaList(input.types);
549
+ for (const type of types) if (!ALL_TYPES.includes(type)) return invalid(context, `invalid type ${type}`);
550
+ const limit = listLimit(context, input, MAX_LIST, 100);
551
+ const rows = allRows(context, "conversations")
552
+ .filter((conversation) => {
553
+ if (input.exclude_archived === true && conversation.is_archived === true) return false;
554
+ if (isPublicChannel(conversation)) return types.includes("public_channel");
555
+ if (!isMember(context, conversation.id, actor.id)) return false;
556
+ if (conversation.is_im === true) return types.includes("im");
557
+ if (conversation.is_mpim === true) return types.includes("mpim");
558
+ return types.includes("private_channel");
559
+ })
560
+ .sort((left, right) => (left.id < right.id ? -1 : 1));
561
+ const views = new Map();
562
+ const viewOf = (conversation) => {
563
+ if (!views.has(conversation.id)) views.set(conversation.id, conversationView(context, conversation, actor.id, false));
564
+ return views.get(conversation.id);
565
+ };
566
+ const envelope = { ok: true, channels: [], response_metadata: { next_cursor: "" } };
567
+ const fit = (candidates) => fitPage(context, candidates.map(viewOf), envelope, "TOO_MANY_ROWS", "one conversation");
568
+ const { page, nextCursor } = paginateIds(context, rows, input.cursor, "team", limit, (row) => row.id, fit);
569
+ return { ok: true, channels: page.map(viewOf), response_metadata: { next_cursor: nextCursor } };
570
+ },
571
+
572
+ "conversations.info": (input, context) => {
573
+ const actor = requireActor(context);
574
+ const conversation = openConversation(context, actor, channelArg(context, input));
575
+ return { ok: true, channel: conversationView(context, conversation, actor.id, input.include_num_members === true) };
576
+ },
577
+
578
+ "conversations.members": (input, context) => {
579
+ const actor = requireActor(context);
580
+ const conversation = openConversation(context, actor, channelArg(context, input));
581
+ const limit = listLimit(context, input, MAX_LIST, 100);
582
+ const members = memberIds(context, conversation.id);
583
+ const envelope = { ok: true, members: [], response_metadata: { next_cursor: "" } };
584
+ const fit = (candidates) => fitPage(context, candidates, envelope, "TOO_MANY_ROWS", "one member id");
585
+ const { page, nextCursor } = paginateIds(context, members, input.cursor, "user", limit, (id) => id, fit);
586
+ return { ok: true, members: page, response_metadata: { next_cursor: nextCursor } };
587
+ },
588
+
589
+ "conversations.create": (input, context) => {
590
+ const actor = requireActor(context);
591
+ const name = String(input.name).replace(/^#/, "").toLowerCase();
592
+ if (!CHANNEL_NAME.test(name)) return fail(context, "INVALID_NAME");
593
+ if (allRows(context, "conversations").some((row) => row.name === name)) return fail(context, "NAME_TAKEN");
594
+ const team = workspace(context);
595
+ const created = nowSeconds(context);
596
+ const isPrivate = input.is_private === true;
597
+ const conversation = {
598
+ id: nextConversationId(team, "C"),
599
+ name,
600
+ is_channel: true,
601
+ is_group: false,
602
+ is_im: false,
603
+ is_mpim: false,
604
+ is_private: isPrivate,
605
+ is_archived: false,
606
+ is_general: false,
607
+ created,
608
+ creator: actor.id,
609
+ topic: { value: "", creator: "", last_set: 0 },
610
+ purpose: { value: "", creator: "", last_set: 0 },
611
+ name_normalized: name,
612
+ updated: created * 1000,
613
+ };
614
+ context.state.put("conversations", conversation.id, conversation);
615
+ addMembership(context, conversation, actor.id);
616
+ saveWorkspace(context, team);
617
+ context.events.emit("channel.created", { channel: conversation.id, name, creator: actor.id, is_private: isPrivate });
618
+ return { ok: true, channel: createdConversationView(conversation, 1) };
619
+ },
620
+
621
+ "conversations.join": (input, context) => {
622
+ const actor = requireActor(context);
623
+ const conversation = openConversation(context, actor, channelArg(context, input));
624
+ if (!isPublicChannel(conversation)) return fail(context, "METHOD_NOT_SUPPORTED_FOR_CHANNEL_TYPE");
625
+ requireActive(context, conversation);
626
+ if (isMember(context, conversation.id, actor.id)) {
627
+ return {
628
+ ok: true,
629
+ channel: conversationView(context, conversation, actor.id, true),
630
+ warning: "already_in_channel",
631
+ response_metadata: { warnings: ["already_in_channel"] },
632
+ };
633
+ }
634
+ const team = workspace(context);
635
+ addMembership(context, conversation, actor.id);
636
+ joinMessage(context, team, conversation, actor);
637
+ saveWorkspace(context, team);
638
+ return { ok: true, channel: conversationView(context, conversation, actor.id, true) };
639
+ },
640
+
641
+ "conversations.invite": (input, context) => {
642
+ const actor = requireActor(context);
643
+ const conversation = openConversation(context, actor, channelArg(context, input));
644
+ if (conversation.is_im === true) return fail(context, "METHOD_NOT_SUPPORTED_FOR_CHANNEL_TYPE");
645
+ requireMember(context, conversation, actor.id);
646
+ requireActive(context, conversation);
647
+ const userIds = [...new Set(commaList(input.users))];
648
+ if (userIds.length === 0) return invalid(context, "users must list at least one user id");
649
+ const invited = [];
650
+ for (const userId of userIds) {
651
+ if (userId === actor.id) return fail(context, "CANT_INVITE_SELF");
652
+ const user = context.state.get("users", userId);
653
+ if (user === null || user.deleted === true) return fail(context, "USER_NOT_FOUND");
654
+ if (isMember(context, conversation.id, userId)) return fail(context, "ALREADY_IN_CHANNEL");
655
+ invited.push(user);
656
+ }
657
+ const team = workspace(context);
658
+ for (const user of invited) {
659
+ addMembership(context, conversation, user.id, actor.id);
660
+ joinMessage(context, team, conversation, user, actor.id);
661
+ }
662
+ saveWorkspace(context, team);
663
+ return { ok: true, channel: conversationView(context, conversation, actor.id, true) };
664
+ },
665
+
666
+ "conversations.open": (input, context) => {
667
+ const actor = requireActor(context);
668
+ const hasUsers = input.users !== undefined;
669
+ const hasChannel = input.channel !== undefined;
670
+ if (hasUsers === hasChannel) return invalid(context, "exactly one of users or channel is required");
671
+ const returnIm = input.return_im === true;
672
+ if (hasChannel) {
673
+ const conversation = visibleConversation(context, actor.id, input.channel);
674
+ return {
675
+ ok: true,
676
+ no_op: true,
677
+ already_open: true,
678
+ channel: returnIm ? conversationView(context, conversation, actor.id, true) : { id: conversation.id },
679
+ };
680
+ }
681
+ const userIds = [...new Set(commaList(input.users))].filter((id) => id !== actor.id);
682
+ if (userIds.length === 0) return invalid(context, "users must name at least one other member");
683
+ if (userIds.length > MAX_OPEN_USERS) return invalid(context, `users may name at most ${MAX_OPEN_USERS} members`);
684
+ const users = [];
685
+ for (const userId of userIds) {
686
+ const user = context.state.get("users", userId);
687
+ if (user === null || user.deleted === true) return fail(context, "USER_NOT_FOUND");
688
+ users.push(user);
689
+ }
690
+ const wanted = [actor.id, ...userIds].sort();
691
+ // The conversation's member count is known before any id is allocated: refuse here, naming only the workspace
692
+ // limit, instead of failing a post-write membership scan that would name the rolled-back id.
693
+ requireWithinBound(context, "memberships", wanted.length, "members of the requested conversation");
694
+ const existing = allRows(context, "conversations").find((conversation) => {
695
+ if (userIds.length === 1 ? conversation.is_im !== true : conversation.is_mpim !== true) return false;
696
+ const members = memberIds(context, conversation.id);
697
+ return members.length === wanted.length && members.every((id, index) => id === wanted[index]);
698
+ });
699
+ if (existing !== undefined) {
700
+ return {
701
+ ok: true,
702
+ no_op: true,
703
+ already_open: true,
704
+ channel: returnIm ? conversationView(context, existing, actor.id, true) : { id: existing.id },
705
+ };
706
+ }
707
+ const team = workspace(context);
708
+ const created = nowSeconds(context);
709
+ const isIm = userIds.length === 1;
710
+ const groupName = isIm ? "" : groupDmName([actor, ...users].map((user) => user.name).sort());
711
+ const conversation = defined({
712
+ id: nextConversationId(team, isIm ? "D" : "C"),
713
+ name: groupName,
714
+ is_channel: false,
715
+ is_group: false,
716
+ is_im: isIm,
717
+ is_mpim: !isIm,
718
+ is_private: true,
719
+ is_archived: false,
720
+ is_general: false,
721
+ created,
722
+ creator: isIm ? "" : actor.id,
723
+ user: isIm ? userIds[0] : undefined,
724
+ topic: { value: "", creator: "", last_set: 0 },
725
+ purpose: { value: "", creator: "", last_set: 0 },
726
+ name_normalized: groupName,
727
+ updated: created * 1000,
728
+ });
729
+ context.state.put("conversations", conversation.id, conversation);
730
+ for (const id of wanted) addMembership(context, conversation, id);
731
+ saveWorkspace(context, team);
732
+ return {
733
+ ok: true,
734
+ no_op: false,
735
+ already_open: false,
736
+ channel: returnIm ? createdConversationView(conversation, wanted.length) : { id: conversation.id },
737
+ };
738
+ },
739
+
740
+ "conversations.archive": (input, context) => {
741
+ const actor = requireActor(context);
742
+ const conversation = openConversation(context, actor, channelArg(context, input), { member: true });
743
+ if (conversation.is_general === true || conversation.is_im === true) return fail(context, "METHOD_NOT_SUPPORTED_FOR_CHANNEL_TYPE");
744
+ if (conversation.is_archived === true) return fail(context, "ALREADY_ARCHIVED");
745
+ context.state.put("conversations", conversation.id, { ...conversation, is_archived: true, updated: nowSeconds(context) * 1000 });
746
+ return { ok: true };
747
+ },
748
+
749
+ "conversations.set-topic": (input, context) => {
750
+ const actor = requireActor(context);
751
+ const conversation = openConversation(context, actor, channelArg(context, input), { member: true, active: true });
752
+ const now = nowSeconds(context);
753
+ const updated = { ...conversation, topic: { value: input.topic, creator: actor.id, last_set: now }, updated: now * 1000 };
754
+ context.state.put("conversations", conversation.id, updated);
755
+ return { ok: true, channel: conversationView(context, updated, actor.id, false) };
756
+ },
757
+
758
+ "conversations.history": (input, context) => {
759
+ const actor = requireActor(context);
760
+ const conversation = openConversation(context, actor, channelArg(context, input), { member: true });
761
+ const limit = listLimit(context, input, MAX_HISTORY, 100);
762
+ let rows = channelMessages(context, conversation.id).filter(isTopLevel).reverse();
763
+ rows = tsWindow(context, rows, input);
764
+ if (input.cursor !== undefined) {
765
+ const anchor = decodeCursor(input.cursor, "next_ts");
766
+ if (anchor === null || !isTs(anchor)) return fail(context, "INVALID_CURSOR");
767
+ rows = rows.filter((row) => compareTs(row.ts, anchor) < 0);
768
+ }
769
+ const pinCount = prefixRows(context, "pins", `${conversation.id}:`).length;
770
+ const candidates = rows.slice(0, limit).map((row) => messageView(row, false));
771
+ const envelope = { ok: true, messages: [], has_more: true, pin_count: pinCount, response_metadata: { next_cursor: "" } };
772
+ const count = fitPage(context, candidates, envelope, "RESPONSE_TOO_LARGE", `message ${candidates[0]?.ts}`);
773
+ const page = candidates.slice(0, count);
774
+ const hasMore = rows.length > page.length;
775
+ return {
776
+ ok: true,
777
+ messages: page,
778
+ has_more: hasMore,
779
+ pin_count: pinCount,
780
+ response_metadata: { next_cursor: hasMore ? encodeCursor("next_ts", page[page.length - 1].ts) : "" },
781
+ };
782
+ },
783
+
784
+ "conversations.replies": (input, context) => {
785
+ const actor = requireActor(context);
786
+ const conversation = openConversation(context, actor, channelArg(context, input), { member: true });
787
+ const threadTs = exactlyOne(context, input, "ts", "thread_ts");
788
+ const parent = findMessage(context, conversation.id, threadTs);
789
+ if (parent === null || !isParent(parent)) return fail(context, "THREAD_NOT_FOUND");
790
+ const limit = listLimit(context, input, MAX_HISTORY, 100);
791
+ let replies = channelMessages(context, conversation.id).filter((row) => row.thread_ts === parent.ts && row.ts !== parent.ts);
792
+ replies = tsWindow(context, replies, input);
793
+ let first = true;
794
+ if (input.cursor !== undefined) {
795
+ const anchor = decodeCursor(input.cursor, "next_ts");
796
+ if (anchor === null || !isTs(anchor)) return fail(context, "INVALID_CURSOR");
797
+ replies = replies.filter((row) => compareTs(row.ts, anchor) > 0);
798
+ first = false;
799
+ }
800
+ // The parent leads the first page and counts toward its budget. When only the parent fits, the page ends at the
801
+ // parent and its ts is the cursor: every reply is newer, so the next page starts at the first reply.
802
+ const lead = first ? [messageView(parent, false)] : [];
803
+ const candidates = [...lead, ...replies.slice(0, limit).map((row) => messageView(row, false))];
804
+ const envelope = { ok: true, messages: [], has_more: true, response_metadata: { next_cursor: "" } };
805
+ const count = fitPage(context, candidates, envelope, "RESPONSE_TOO_LARGE", `message ${candidates[0]?.ts}`);
806
+ const messages = candidates.slice(0, count);
807
+ const pageReplies = messages.length - lead.length;
808
+ const hasMore = replies.length > pageReplies;
809
+ return {
810
+ ok: true,
811
+ messages,
812
+ has_more: hasMore,
813
+ response_metadata: { next_cursor: hasMore ? encodeCursor("next_ts", messages[messages.length - 1].ts) : "" },
814
+ };
815
+ },
816
+
817
+ "chat.post-message": (input, context) => postMessage(context, requireActor(context), input, false),
818
+
819
+ "chat.reply": (input, context) => postMessage(context, requireActor(context), input, true),
820
+
821
+ "chat.update": (input, context) => {
822
+ const actor = requireActor(context);
823
+ const conversation = openConversation(context, actor, channelArg(context, input), { member: true, active: true });
824
+ const message = requireMessage(context, conversation.id, input.ts);
825
+ if (message.user !== actor.id || message.subtype === "tombstone" || message.subtype === "channel_join") {
826
+ return fail(context, "CANT_UPDATE_MESSAGE");
827
+ }
828
+ const text = typeof input.text === "string" ? input.text : undefined;
829
+ const blocks = blocksArg(context, input.blocks);
830
+ if ((text === undefined || text.trim().length === 0) && (blocks === undefined || blocks.length === 0)) return fail(context, "NO_TEXT");
831
+ const updated = { ...message };
832
+ if (text !== undefined) updated.text = text;
833
+ if (blocks !== undefined) updated.blocks = blocks;
834
+ else if (text !== undefined) delete updated.blocks;
835
+ updated.edited = { user: actor.id, ts: renderTs(nowSeconds(context), 0) };
836
+ context.state.put("messages", messageRowId(conversation.id, message.ts), updated);
837
+ return { ok: true, channel: conversation.id, ts: message.ts, text: updated.text, message: messageView(updated, false) };
838
+ },
839
+
840
+ "chat.delete": (input, context) => {
841
+ const actor = requireActor(context);
842
+ const conversation = openConversation(context, actor, channelArg(context, input), { member: true, active: true });
843
+ const message = requireMessage(context, conversation.id, input.ts);
844
+ if (message.subtype === "tombstone") return fail(context, "MESSAGE_NOT_FOUND");
845
+ if (message.user !== actor.id && actor.is_admin !== true) return fail(context, "CANT_DELETE_MESSAGE");
846
+ unpin(context, conversation.id, message.ts);
847
+ const hasReplies = isParent(message) && (message.reply_count ?? 0) > 0;
848
+ if (hasReplies) {
849
+ const tombstone = {
850
+ channel: message.channel,
851
+ ts: message.ts,
852
+ type: "message",
853
+ subtype: "tombstone",
854
+ user: message.user,
855
+ text: "This message was deleted.",
856
+ thread_ts: message.ts,
857
+ reply_count: message.reply_count,
858
+ reply_users: message.reply_users,
859
+ reply_users_count: message.reply_users_count,
860
+ latest_reply: message.latest_reply,
861
+ reactions: [],
862
+ permalink: message.permalink,
863
+ team: message.team,
864
+ };
865
+ context.state.put("messages", messageRowId(conversation.id, message.ts), defined(tombstone));
866
+ } else {
867
+ context.state.delete("messages", messageRowId(conversation.id, message.ts));
868
+ if (!isParent(message)) refreshParent(context, conversation.id, message.thread_ts);
869
+ }
870
+ context.events.emit("message.deleted", { channel: conversation.id, ts: message.ts, user: actor.id, tombstoned: hasReplies });
871
+ return { ok: true, channel: conversation.id, ts: message.ts };
872
+ },
873
+
874
+ "reactions.add": (input, context) => {
875
+ const actor = requireActor(context);
876
+ const name = String(exactlyOne(context, input, "name", "reaction")).replace(/^:|:$/g, "");
877
+ if (!REACTION_NAME.test(name)) return fail(context, "INVALID_NAME");
878
+ const conversation = openConversation(context, actor, channelArg(context, input), { member: true, active: true });
879
+ const message = requireMessage(context, conversation.id, input.timestamp);
880
+ const reactions = Array.isArray(message.reactions) ? message.reactions.map((reaction) => ({ ...reaction, users: [...reaction.users] })) : [];
881
+ const existing = reactions.find((reaction) => reaction.name === name);
882
+ if (existing !== undefined && existing.users.includes(actor.id)) return fail(context, "ALREADY_REACTED");
883
+ if (existing === undefined && reactions.length >= MAX_DISTINCT_REACTIONS) return fail(context, "TOO_MANY_REACTIONS");
884
+ if (existing === undefined) reactions.push({ name, users: [actor.id], count: 1 });
885
+ else {
886
+ existing.users.push(actor.id);
887
+ existing.count = existing.users.length;
888
+ }
889
+ context.state.put("messages", messageRowId(conversation.id, message.ts), { ...message, reactions });
890
+ context.events.emit("reaction.added", { channel: conversation.id, ts: message.ts, user: actor.id, reaction: name });
891
+ return { ok: true };
892
+ },
893
+
894
+ "reactions.remove": (input, context) => {
895
+ const actor = requireActor(context);
896
+ const name = String(exactlyOne(context, input, "name", "reaction")).replace(/^:|:$/g, "");
897
+ if (!REACTION_NAME.test(name)) return fail(context, "INVALID_NAME");
898
+ const conversation = openConversation(context, actor, channelArg(context, input), { member: true, active: true });
899
+ const message = requireMessage(context, conversation.id, input.timestamp);
900
+ const reactions = Array.isArray(message.reactions) ? message.reactions.map((reaction) => ({ ...reaction, users: [...reaction.users] })) : [];
901
+ const existing = reactions.find((reaction) => reaction.name === name);
902
+ if (existing === undefined || !existing.users.includes(actor.id)) return fail(context, "NO_REACTION");
903
+ existing.users = existing.users.filter((id) => id !== actor.id);
904
+ existing.count = existing.users.length;
905
+ const remaining = reactions.filter((reaction) => reaction.count > 0);
906
+ context.state.put("messages", messageRowId(conversation.id, message.ts), { ...message, reactions: remaining });
907
+ return { ok: true };
908
+ },
909
+
910
+ "pins.add": (input, context) => {
911
+ const actor = requireActor(context);
912
+ const conversation = openConversation(context, actor, channelArg(context, input), { member: true, active: true });
913
+ const message = requireMessage(context, conversation.id, input.timestamp);
914
+ if (context.state.get("pins", pinRowId(conversation.id, message.ts)) !== null) return fail(context, "ALREADY_PINNED");
915
+ const pinned = prefixRows(context, "pins", `${conversation.id}:`);
916
+ const pinBound = limitsOf(context).pins;
917
+ if (pinned.length >= pinBound) {
918
+ return fail(context, "TOO_MANY_ROWS", `pin limit reached: ${conversation.id} already holds ${pinned.length} pins, the most this workspace supports (workspace.limits.pins)`);
919
+ }
920
+ context.state.put("pins", pinRowId(conversation.id, message.ts), {
921
+ channel: conversation.id,
922
+ message_ts: message.ts,
923
+ created: nowSeconds(context),
924
+ created_by: actor.id,
925
+ });
926
+ context.state.put("messages", messageRowId(conversation.id, message.ts), { ...message, pinned_to: [conversation.id] });
927
+ return { ok: true };
928
+ },
929
+
930
+ "pins.remove": (input, context) => {
931
+ const actor = requireActor(context);
932
+ const conversation = openConversation(context, actor, channelArg(context, input), { member: true, active: true });
933
+ const ts = input.timestamp;
934
+ if (!isTs(ts) || context.state.get("pins", pinRowId(conversation.id, ts)) === null) return fail(context, "NOT_PINNED");
935
+ unpin(context, conversation.id, ts);
936
+ return { ok: true };
937
+ },
938
+
939
+ "pins.list": (input, context) => {
940
+ const actor = requireActor(context);
941
+ const conversation = openConversation(context, actor, channelArg(context, input), { member: true });
942
+ const items = prefixRows(context, "pins", `${conversation.id}:`)
943
+ .map((pin) => ({ pin, message: findMessage(context, conversation.id, pin.message_ts) }))
944
+ .filter((entry) => entry.message !== null)
945
+ .sort((left, right) => right.pin.created - left.pin.created || compareTs(right.pin.message_ts, left.pin.message_ts))
946
+ .map(({ pin, message }) => ({
947
+ type: "message",
948
+ channel: conversation.id,
949
+ created: pin.created,
950
+ created_by: pin.created_by,
951
+ message: messageView(message, true),
952
+ }));
953
+ // pins.list has no pagination in the Web API: a pin set whose encoded body exceeds the budget is refused explicitly.
954
+ const budget = responseBudget(context);
955
+ const bytes = jsonBytes({ ok: true, items });
956
+ if (bytes > budget) {
957
+ return fail(context, "RESPONSE_TOO_LARGE", `the ${items.length} pinned messages of ${conversation.id} encode to ${bytes} bytes, over the response budget of ${budget} bytes (workspace.limits.response_bytes)`);
958
+ }
959
+ return { ok: true, items };
960
+ },
961
+
962
+ "search.messages": (input, context) => {
963
+ const actor = requireActor(context);
964
+ const query = String(input.query ?? "").trim();
965
+ if (query.length === 0) return fail(context, "NO_QUERY");
966
+ // The framework decodes query strings and form bodies leniently: malformed percent-encoding (`%E0%A4%A`) arrives
967
+ // as U+FFFD. A query holding U+FFFD is a mangled request, so it fails instead of silently matching nothing. A
968
+ // correctly encoded U+FFFD (`%EF%BF%BD`) is rejected the same way.
969
+ if (query.includes("\uFFFD")) return invalid(context, "query contains an invalid character (U+FFFD); check the percent-encoding");
970
+ const count = input.count === undefined ? 20 : input.count;
971
+ if (count < 1) return invalid(context, "invalid_count");
972
+ const perPage = Math.min(count, MAX_SEARCH);
973
+ const page = input.page === undefined ? 1 : input.page;
974
+ if (page < 1) return invalid(context, "invalid_page");
975
+ const sort = input.sort ?? "score";
976
+ if (sort !== "score" && sort !== "timestamp") return invalid(context, "sort must be score or timestamp");
977
+ const direction = input.sort_dir ?? "desc";
978
+ if (direction !== "asc" && direction !== "desc") return invalid(context, "sort_dir must be asc or desc");
979
+ let parsed;
980
+ try {
981
+ parsed = parseQuery(query);
982
+ } catch (error) {
983
+ if (error instanceof SearchError) return invalid(context, error.message);
984
+ throw error;
985
+ }
986
+ const users = usersById(context);
987
+ const team = workspace(context);
988
+ const matches = [];
989
+ for (const conversation of conversationsOf(context, actor.id)) {
990
+ const pinned = new Set(prefixRows(context, "pins", `${conversation.id}:`).map((pin) => pin.message_ts));
991
+ const env = {
992
+ conversation,
993
+ actorId: actor.id,
994
+ authorHandle: (userId) => users.get(userId)?.name ?? null,
995
+ partnerHandle: (im) => {
996
+ const partner = memberIds(context, im.id).find((id) => id !== actor.id);
997
+ return partner === undefined ? null : (users.get(partner)?.name ?? null);
998
+ },
999
+ isPinned: (message) => pinned.has(message.ts),
1000
+ };
1001
+ for (const message of channelMessages(context, conversation.id)) {
1002
+ if (message.subtype === "tombstone" || message.subtype === "channel_join") continue;
1003
+ const score = scoreMessage(parsed, message, env);
1004
+ if (score === null) continue;
1005
+ matches.push({ score, message, conversation });
1006
+ }
1007
+ }
1008
+ matches.sort((left, right) => {
1009
+ const primary = sort === "score" ? left.score - right.score : compareTs(left.message.ts, right.message.ts);
1010
+ const ordered = direction === "asc" ? primary : -primary;
1011
+ return ordered !== 0 ? ordered : compareTs(right.message.ts, left.message.ts);
1012
+ });
1013
+ const matchView = ({ message, conversation }) => ({
1014
+ type: "message",
1015
+ iid: `${conversation.id}:${message.ts}`,
1016
+ channel: {
1017
+ id: conversation.id,
1018
+ name: conversation.name,
1019
+ is_channel: conversation.is_channel,
1020
+ is_private: conversation.is_private,
1021
+ is_im: conversation.is_im,
1022
+ is_mpim: conversation.is_mpim,
1023
+ },
1024
+ user: message.user,
1025
+ username: users.get(message.user)?.name ?? message.user,
1026
+ text: message.text,
1027
+ ts: message.ts,
1028
+ team: team.id,
1029
+ permalink: message.permalink,
1030
+ });
1031
+ // Page arithmetic stays uniform: per_page is the requested count, lowered only as far as needed for every page of
1032
+ // the result set to fit the response budget, so page N+1 always starts where page N ended and no match is skipped.
1033
+ const total = matches.length;
1034
+ const budget = responseBudget(context);
1035
+ const pagingOf = (size, pageNumber, sliceLength) => {
1036
+ const pages = Math.max(1, Math.ceil(total / size));
1037
+ const first = (pageNumber - 1) * size;
1038
+ return {
1039
+ pagination: {
1040
+ total_count: total,
1041
+ page: pageNumber,
1042
+ per_page: size,
1043
+ page_count: pages,
1044
+ first: total === 0 ? 0 : Math.min(first + 1, total + 1),
1045
+ last: Math.min(first + sliceLength, total),
1046
+ },
1047
+ paging: { count: size, total, page: pageNumber, pages },
1048
+ total,
1049
+ };
1050
+ };
1051
+ const envelopeBytes = jsonBytes({ ok: true, query, messages: { matches: [], ...pagingOf(perPage, page, perPage) } }) + CURSOR_RESERVE;
1052
+ const sizes = matches.map((match) => jsonBytes(matchView(match)));
1053
+ const size = uniformPageSize(sizes, perPage, envelopeBytes, budget);
1054
+ if (size === 0) {
1055
+ return fail(context, "RESPONSE_TOO_LARGE", `a matching message exceeds the response budget of ${budget} bytes (workspace.limits.response_bytes)`);
1056
+ }
1057
+ const start = (page - 1) * size;
1058
+ const slice = matches.slice(start, start + size);
1059
+ const paging = pagingOf(size, page, slice.length);
1060
+ return {
1061
+ ok: true,
1062
+ query,
1063
+ messages: {
1064
+ matches: slice.map(matchView),
1065
+ ...paging,
1066
+ },
1067
+ };
1068
+ },
1069
+ };
1070
+
1071
+ // ---------------------------------------------------------------------------------------------
1072
+ // HTTP codecs (pure): Slack Web API query/form spelling ⇄ canonical arguments
1073
+ // ---------------------------------------------------------------------------------------------
1074
+
1075
+ const S = "string";
1076
+ const I = "integer";
1077
+ const B = "boolean";
1078
+
1079
+ /** Which parameters each Slack method accepts and how they are typed on the wire. */
1080
+ const METHODS = {
1081
+ "auth.test": { operationId: "auth.test", fields: {} },
1082
+ "users.list": { operationId: "users.list", fields: { cursor: S, limit: I, include_locale: B } },
1083
+ "users.info": { operationId: "users.info", fields: { user: S, include_locale: B } },
1084
+ "users.profile.get": { operationId: "users.profile.get", fields: { user: S, include_labels: B } },
1085
+ "conversations.list": {
1086
+ operationId: "conversations.list",
1087
+ fields: { types: S, exclude_archived: B, limit: I, cursor: S, team_id: S },
1088
+ },
1089
+ "conversations.info": { operationId: "conversations.info", fields: { channel: S, include_num_members: B, include_locale: B } },
1090
+ "conversations.members": { operationId: "conversations.members", fields: { channel: S, limit: I, cursor: S } },
1091
+ "conversations.history": {
1092
+ operationId: "conversations.history",
1093
+ fields: { channel: S, limit: I, cursor: S, oldest: S, latest: S, inclusive: B, include_all_metadata: B },
1094
+ },
1095
+ "conversations.replies": {
1096
+ operationId: "conversations.replies",
1097
+ fields: { channel: S, ts: S, limit: I, cursor: S, oldest: S, latest: S, inclusive: B },
1098
+ },
1099
+ "pins.list": { operationId: "pins.list", fields: { channel: S } },
1100
+ "search.messages": {
1101
+ operationId: "search.messages",
1102
+ fields: { query: S, count: I, page: I, sort: S, sort_dir: S, highlight: B, team_id: S },
1103
+ },
1104
+ "conversations.create": { operationId: "conversations.create", fields: { name: S, is_private: B, team_id: S }, mutation: true },
1105
+ "conversations.join": { operationId: "conversations.join", fields: { channel: S }, mutation: true },
1106
+ "conversations.invite": { operationId: "conversations.invite", fields: { channel: S, users: S, force: B }, mutation: true },
1107
+ "conversations.open": { operationId: "conversations.open", fields: { users: S, channel: S, return_im: B }, mutation: true },
1108
+ "conversations.archive": { operationId: "conversations.archive", fields: { channel: S }, mutation: true },
1109
+ "conversations.setTopic": { operationId: "conversations.set-topic", fields: { channel: S, topic: S }, mutation: true },
1110
+ "chat.postMessage": {
1111
+ operationId: "chat.post-message",
1112
+ fields: {
1113
+ channel: S,
1114
+ text: S,
1115
+ blocks: S,
1116
+ thread_ts: S,
1117
+ reply_broadcast: B,
1118
+ mrkdwn: B,
1119
+ unfurl_links: B,
1120
+ unfurl_media: B,
1121
+ link_names: B,
1122
+ parse: S,
1123
+ username: S,
1124
+ icon_emoji: S,
1125
+ },
1126
+ mutation: true,
1127
+ },
1128
+ "chat.update": { operationId: "chat.update", fields: { channel: S, ts: S, text: S, blocks: S, as_user: B }, mutation: true },
1129
+ "chat.delete": { operationId: "chat.delete", fields: { channel: S, ts: S, as_user: B }, mutation: true },
1130
+ "reactions.add": { operationId: "reactions.add", fields: { channel: S, timestamp: S, name: S }, mutation: true },
1131
+ "reactions.remove": { operationId: "reactions.remove", fields: { channel: S, timestamp: S, name: S }, mutation: true },
1132
+ "pins.add": { operationId: "pins.add", fields: { channel: S, timestamp: S }, mutation: true },
1133
+ "pins.remove": { operationId: "pins.remove", fields: { channel: S, timestamp: S }, mutation: true },
1134
+ };
1135
+
1136
+ function decodeArguments(request, fields) {
1137
+ const source = params(request);
1138
+ const args = {};
1139
+ for (const [name, kind] of Object.entries(fields)) {
1140
+ const value = kind === S ? str(source, name) : kind === I ? int(source, name) : bool(source, name);
1141
+ if (value !== undefined) args[name] = value;
1142
+ }
1143
+ return args;
1144
+ }
1145
+
1146
+ function encodeSlack({ invocation, outcome }) {
1147
+ if (outcome.status !== "ok") return slackError(invocation, outcome);
1148
+ return { body: { kind: "json", value: outcome.value } };
1149
+ }
1150
+
1151
+ function routeId(prefix, method) {
1152
+ return `${prefix}-${method.replace(/\./g, "-").replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase()}`;
1153
+ }
1154
+
1155
+ const http = {};
1156
+ for (const [method, spec] of Object.entries(METHODS)) {
1157
+ const codec = {
1158
+ decode: (request) => {
1159
+ const args = decodeArguments(request, spec.fields);
1160
+ return spec.mutation ? operationInput(request, args) : { arguments: args };
1161
+ },
1162
+ encode: encodeSlack,
1163
+ };
1164
+ if (spec.mutation) http[routeId("post", method)] = codec;
1165
+ else {
1166
+ http[routeId("get", method)] = codec;
1167
+ http[routeId("post", method)] = codec;
1168
+ }
1169
+ }
1170
+
1171
+ export default { operations, http };