@amykit/plugin-slack 0.5.0 → 0.7.0

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.
@@ -1,4 +1,4 @@
1
- import { randomUUID } from "node:crypto";
1
+ import { createHash, randomUUID } from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { TooLarge } from "./SlackApi.js";
@@ -18,12 +18,32 @@ export const DEFAULT_RETENTION_DAYS = 30;
18
18
  /** Pruning walks the state directory, so it happens at most this often. */
19
19
  const PRUNE_EVERY_MS = 60 * 60 * 1000;
20
20
  const DAY_MS = 24 * 60 * 60 * 1000;
21
+ /** A reaction whose list of users is too short to say who is on it. */
22
+ const UNKNOWN = Symbol("unknown");
23
+ /** ✅ settles it, 💬 asks something first. */
24
+ export const DEFAULT_MARKS = { decision: "white_check_mark", discussion: "speech_balloon" };
21
25
  /** What marks a root as amy's, and whose work it opened. */
22
26
  const ROOT_EVENT = "amy_work_thread";
27
+ const ASK_EVENT = "amy_ask";
28
+ const ASK_FALLBACK_EVENT = "amy_ask_fallback";
29
+ const ASK_AMBIGUOUS_EVENT = "amy_ask_ambiguous";
30
+ const ASK_TAKEN_EVENT = "amy_ask_taken";
31
+ const KEYCAPS = ["one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "keycap_ten"];
32
+ const APPROVAL_REACTIONS = ["white_check_mark", "x"];
33
+ const SUBMIT_REACTION = "white_check_mark";
34
+ /** A conversation's thread is read at most this often: Slack may allow conversations.replies once a minute. */
35
+ const CHAT_THREAD_EVERY_MS = 60 * 1000;
23
36
  export class SlackConversation {
24
37
  api;
25
38
  options;
26
- threads;
39
+ asks = true;
40
+ threads = true;
41
+ edit = true;
42
+ /** The channel: its conversations and claims are its own. */
43
+ get source() {
44
+ return `slack:${this.options.channel}`;
45
+ }
46
+ threadMap;
27
47
  filesDirectory;
28
48
  opening = new Map();
29
49
  botUser;
@@ -31,21 +51,23 @@ export class SlackConversation {
31
51
  constructor(api, options) {
32
52
  this.api = api;
33
53
  this.options = options;
34
- this.threads = new ThreadMap(options.directory, {
54
+ this.threadMap = new ThreadMap(options.directory, {
35
55
  maxWaitMs: options.lockWaitMs,
36
56
  leaseMs: options.leaseMs,
57
+ readOnly: options.readOnly,
37
58
  // A root replaced in another channel takes its downloads with it.
38
59
  onDisplaced: (ts) => this.dropFiles(ts),
39
60
  });
40
61
  this.filesDirectory = path.join(options.directory, "files");
41
62
  // The operator's screenshots, fetched with the bot's token: private.
42
- privateDirectory(this.filesDirectory);
63
+ if (!options.readOnly)
64
+ privateDirectory(this.filesDirectory);
43
65
  }
44
66
  open(workId, title) {
45
67
  let known;
46
68
  try {
47
69
  // Known first, which marks it used, so the prune after it keeps it.
48
- known = this.threads.known(workId, this.options.channel);
70
+ known = this.threadMap.known(workId, this.options.channel);
49
71
  }
50
72
  catch (error) {
51
73
  // A promise-returning method rejects; it does not throw at the caller.
@@ -64,42 +86,78 @@ export class SlackConversation {
64
86
  }
65
87
  async post(thread, message) {
66
88
  if (message.files?.length) {
67
- // Uploading needs `files:write`, which this plugin does not ask for:
68
- // amy asks in words, and the operator's pictures come the other way.
69
89
  throw new Error("@amykit/plugin-slack does not upload files; post their paths as text");
70
90
  }
71
- // Writing to a thread is using it, as reading its replies is.
72
- this.threads.used(thread.id);
91
+ this.threadMap.used(thread.id);
73
92
  this.pruneNowAndThen();
93
+ if (!message.ask)
94
+ return this.postText(thread.id, message.text);
95
+ const ask = message.ask;
96
+ if (!validAsk(ask))
97
+ return this.postAskFallback(thread.id, message.text, ask);
98
+ if (ask.form !== "approval" && ask.answers.length > KEYCAPS.length) {
99
+ return this.postAskFallback(thread.id, message.text, ask);
100
+ }
74
101
  const { body } = await this.api.call("chat.postMessage", {
75
102
  channel: this.options.channel,
76
103
  thread_ts: thread.id,
77
- text: message.text,
104
+ text: renderAsk(message.text, ask, false),
105
+ metadata: askMetadata(ASK_EVENT, ask),
78
106
  });
79
- return String(body.ts);
107
+ const ts = String(body.ts);
108
+ this.rememberAskedFrom(thread.id, ts);
109
+ try {
110
+ for (const name of reactionsFor(ask)) {
111
+ await this.api.call("reactions.add", { channel: this.options.channel, timestamp: ts, name });
112
+ }
113
+ }
114
+ catch {
115
+ // The ask stands as posted, its answers listed on it: the operator
116
+ // reacts by hand, which is read as any reaction is, or types.
117
+ }
118
+ return ts;
80
119
  }
81
120
  async replies(thread, since) {
82
121
  const after = instantToMicros(since);
83
122
  if (Number.isNaN(after))
84
123
  throw new Error(`replies since ${since}: not an instant`);
85
- // Reading a thread is using it, whether or not the caller opened it
86
- // first — marked before the prune, so the prune never takes it.
87
- this.threads.used(thread.id);
124
+ this.threadMap.used(thread.id);
88
125
  this.pruneNowAndThen();
89
126
  const bot = await this.botUserId();
90
- const answers = [];
91
- for (const message of await this.threadMessages(thread.id, after)) {
127
+ // A later copy of the same ask must see the earlier decision. Slack has
128
+ // no reaction timestamp, so recover the thread's ask messages instead
129
+ // of retaining an accepted answer in process memory. Where amy recorded
130
+ // the first ask it posted here, one read from there holds both the asks
131
+ // and what is recent: conversations.replies may be limited to one call
132
+ // a minute, so a look makes one.
133
+ const askedFrom = this.askedFrom(thread.id);
134
+ const read = askedFrom === undefined
135
+ ? await this.threadMessages(thread.id, after)
136
+ : await this.threadMessages(thread.id, Math.min(after, askedFrom), true);
137
+ const recent = askedFrom === undefined ? read : read.filter((message) => message.ts !== undefined && tsToMicros(message.ts) > after);
138
+ // Metadata is anybody's to attach: an ask, or the notice amy took one,
139
+ // is read only from a message this bot wrote, so another app cannot
140
+ // turn the operator's reaction into an answer to amy's question.
141
+ const amys = (messages) => messages.filter((message) => message.metadata === undefined || message.user === bot);
142
+ // With no record (an ask from before amy kept one), a recent ask still
143
+ // sends the look back for the whole thread, as it always did.
144
+ const unrecorded = askedFrom === undefined && recent.some((message) => message.user === bot && readAsk(message) !== undefined);
145
+ const messages = amys(unrecorded ? await this.threadMessages(thread.id, 0) : read);
146
+ const answers = await this.askedReplies(thread.id, after, messages);
147
+ for (const message of recent) {
92
148
  if (!this.isOperatorAnswer(message, thread.id, bot))
93
149
  continue;
94
150
  const at = tsToMicros(message.ts);
95
151
  if (at <= after)
96
152
  continue;
97
153
  const { paths, skipped } = await this.downloadAll(thread.id, message.files ?? []);
154
+ const kind = await this.kindOf(message);
98
155
  answers.push({
99
156
  at: microsToInstant(at),
100
157
  author: message.user,
101
158
  text: [message.text ?? "", ...skipped].filter(Boolean).join("\n"),
102
159
  files: paths,
160
+ ...(kind ? { kind } : {}),
103
161
  });
104
162
  }
105
163
  return answers.sort((a, b) => a.at.localeCompare(b.at));
@@ -128,7 +186,7 @@ export class SlackConversation {
128
186
  * from it. The thread stays in Slack; the next `open` starts a new one.
129
187
  */
130
188
  forget(workId) {
131
- const ts = this.threads.forget(workId);
189
+ const ts = this.threadMap.forget(workId);
132
190
  if (ts)
133
191
  this.dropFiles(ts);
134
192
  }
@@ -145,11 +203,183 @@ export class SlackConversation {
145
203
  if (days <= 0)
146
204
  return { threads: 0, files: 0 };
147
205
  const cutoff = now.getTime() - days * DAY_MS;
148
- const threads = this.threads.prune(cutoff);
206
+ const threads = this.threadMap.prune(cutoff);
149
207
  for (const ts of threads)
150
208
  this.dropFiles(ts);
151
209
  return { threads: threads.length, files: pruneFiles(this.filesDirectory, cutoff) };
152
210
  }
211
+ /**
212
+ * The messages in the channel that reached no work, oldest first, until
213
+ * they are settled: a root message the operator posts, and the operator's
214
+ * replies in that root's thread, which is one conversation (S14). The
215
+ * channel is read for them first, so it is read all the time (S20).
216
+ */
217
+ async chat() {
218
+ if (!this.options.readOnly)
219
+ await this.fileChat();
220
+ const directory = path.join(this.chatDirectory(), "inbox");
221
+ return jsonIn(directory)
222
+ .flatMap((name) => {
223
+ const filed = readJson(path.join(directory, name));
224
+ // Filed while somebody else was the operator: theirs to have said, not this operator's to act on.
225
+ return filed && filed.author === this.options.operator ? [filed] : [];
226
+ })
227
+ .sort((a, b) => a.at.localeCompare(b.at));
228
+ }
229
+ async settle(id) {
230
+ const message = /^slack:([^:]+):(\d+\.\d+)$/.exec(id);
231
+ if (!message || message[1] !== this.options.channel)
232
+ throw new Error(`${id} is not a message this channel filed`);
233
+ fs.rmSync(path.join(this.chatDirectory(), "inbox", `${safeName(message[2])}.json`), { force: true });
234
+ }
235
+ /** Says something in a conversation's thread. */
236
+ async say(place, text) {
237
+ const { body } = await this.api.call("chat.postMessage", { channel: this.options.channel, thread_ts: place, text });
238
+ return String(body.ts);
239
+ }
240
+ async revise(_place, id, text) {
241
+ await this.api.call("chat.update", { channel: this.options.channel, ts: id, text });
242
+ }
243
+ /**
244
+ * Files the operator's new root messages, and their replies in the
245
+ * threads of conversations still going, each once. The first read starts
246
+ * from now: what was said in the channel before amy listened is not asked
247
+ * of it. A thread goes quiet a day after the operator last wrote in it.
248
+ */
249
+ async fileChat() {
250
+ const bot = await this.botUserId();
251
+ const chat = this.chatDirectory();
252
+ privateDirectory(chat);
253
+ const cursorFile = path.join(chat, "cursor.json");
254
+ const cursor = readJson(cursorFile)?.oldest;
255
+ if (cursor === undefined) {
256
+ writeJsonFile(cursorFile, { oldest: msToTs(this.clock().getTime()) });
257
+ return;
258
+ }
259
+ // Slack may allow conversations.history once a minute too: the channel is
260
+ // read when that is due, and what was filed is handed out meanwhile.
261
+ const historyQuota = path.join(chat, "history.json");
262
+ if (this.clock().getTime() - (readJson(historyQuota)?.at ?? 0) >= CHAT_THREAD_EVERY_MS) {
263
+ writeJsonFile(historyQuota, { at: this.clock().getTime() });
264
+ writeJsonFile(cursorFile, { oldest: await this.fileRoots(cursor, bot) });
265
+ }
266
+ await this.fileDueReplies(chat, bot);
267
+ }
268
+ /** The chat's own state, by channel: a channel changed in the config starts afresh and never answers the old one's messages. */
269
+ chatDirectory() {
270
+ return path.join(this.options.directory, "chat", safeName(this.options.channel));
271
+ }
272
+ /**
273
+ * One conversation's replies per minute, whichever has waited longest:
274
+ * Slack may allow conversations.replies once a minute for the whole app, not
275
+ * once per thread, so the chat shares that one call rather than queueing.
276
+ */
277
+ async fileDueReplies(chat, bot) {
278
+ const now = this.clock().getTime();
279
+ // Work threads read whenever their work asks, and each read takes the
280
+ // shared allowance. The chat waits for it, but never past its own second
281
+ // slot: work reads under a minute apart would otherwise hold the chat's
282
+ // follow-ups unread for as long as the work goes on. A read Slack refuses
283
+ // for that is a 429, which the API waits out.
284
+ const free = now - (readJson(this.repliesQuota())?.at ?? 0) >= CHAT_THREAD_EVERY_MS;
285
+ const chatReads = path.join(chat, "chat-replies.json");
286
+ const owed = now - (readJson(chatReads)?.at ?? 0) >= 2 * CHAT_THREAD_EVERY_MS;
287
+ if (!free && !owed)
288
+ return;
289
+ const threads = jsonIn(path.join(chat, "threads")).map((name) => {
290
+ const file = path.join(chat, "threads", name);
291
+ return { file, kept: readJson(file) };
292
+ });
293
+ const due = threads
294
+ .filter((one) => one.kept)
295
+ .sort((a, b) => (a.kept.checked ?? 0) - (b.kept.checked ?? 0))[0];
296
+ if (!due)
297
+ return;
298
+ writeJsonFile(chatReads, { at: now });
299
+ await this.fileReplies(due.file, bot);
300
+ }
301
+ /** The operator's root messages since the cursor, each a conversation from now on; answers the new cursor. */
302
+ async fileRoots(cursor, bot) {
303
+ let oldest = cursor;
304
+ for (const message of await this.pages("conversations.history", { channel: this.options.channel, oldest: cursor })) {
305
+ if (!message.ts)
306
+ continue;
307
+ if (tsToMicros(message.ts) > tsToMicros(oldest))
308
+ oldest = message.ts;
309
+ const root = !message.thread_ts || message.thread_ts === message.ts;
310
+ if (!root || !this.isOperatorMessage(message, bot))
311
+ continue;
312
+ this.fileChatMessage(message, message.ts);
313
+ // A newly discovered root waits behind conversations already awaiting a
314
+ // reply; otherwise a steady stream of roots can starve their follow-ups.
315
+ const now = this.clock().getTime();
316
+ writeJsonFile(path.join(this.chatDirectory(), "threads", `${safeName(message.ts)}.json`), { read: message.ts, last: now, checked: now });
317
+ }
318
+ return oldest;
319
+ }
320
+ /** The operator's replies in one conversation's thread, at most once a minute, until it goes quiet. */
321
+ async fileReplies(file, bot) {
322
+ const now = this.clock().getTime();
323
+ const kept = readJson(file);
324
+ if (!kept)
325
+ return;
326
+ const root = path.basename(file, ".json");
327
+ let { read, last } = kept;
328
+ let messages;
329
+ try {
330
+ messages = await this.threadMessages(root, tsToMicros(kept.read));
331
+ }
332
+ catch (error) {
333
+ // The root is gone: that conversation is over, and the others are still read.
334
+ if (/thread_not_found|message_not_found/.test(messageOf(error))) {
335
+ fs.rmSync(file, { force: true });
336
+ // What was asked there is gone with it: nothing is left to answer in.
337
+ const inbox = path.join(this.chatDirectory(), "inbox");
338
+ for (const name of jsonIn(inbox)) {
339
+ if (readJson(path.join(inbox, name))?.place === root)
340
+ fs.rmSync(path.join(inbox, name), { force: true });
341
+ }
342
+ return;
343
+ }
344
+ throw error;
345
+ }
346
+ for (const message of messages) {
347
+ if (!message.ts || message.ts === root || !this.isOperatorMessage(message, bot))
348
+ continue;
349
+ this.fileChatMessage(message, root);
350
+ if (tsToMicros(message.ts) > tsToMicros(read))
351
+ read = message.ts;
352
+ last = now;
353
+ }
354
+ writeJsonFile(file, { read, last, checked: now });
355
+ }
356
+ /** One channel-wide conversations.replies allowance, shared by chat and work threads. */
357
+ repliesQuota() {
358
+ return path.join(this.chatDirectory(), "replies.json");
359
+ }
360
+ fileChatMessage(message, root) {
361
+ const text = (message.text ?? "").trim();
362
+ if (!text)
363
+ return;
364
+ const target = path.join(this.chatDirectory(), "inbox", `${safeName(message.ts)}.json`);
365
+ const filed = {
366
+ id: `slack:${this.options.channel}:${message.ts}`,
367
+ at: microsToInstant(tsToMicros(message.ts)),
368
+ author: message.user,
369
+ text,
370
+ place: root,
371
+ threaded: true,
372
+ };
373
+ writeJsonFile(target, filed);
374
+ }
375
+ /** The operator, writing as themselves: no bot, no other person, no channel event. */
376
+ isOperatorMessage(message, bot) {
377
+ if (message.bot_id || message.user === bot)
378
+ return false;
379
+ if (message.subtype && message.subtype !== "file_share" && message.subtype !== "thread_broadcast")
380
+ return false;
381
+ return message.user === this.options.operator;
382
+ }
153
383
  clock() {
154
384
  return this.options.now?.() ?? new Date();
155
385
  }
@@ -170,6 +400,7 @@ export class SlackConversation {
170
400
  privateDirectory(this.filesDirectory);
171
401
  fs.writeFileSync(this.forgottenMarker(ts), "", { mode: 0o600 });
172
402
  fs.rmSync(this.threadFiles(ts), { recursive: true, force: true });
403
+ fs.rmSync(this.answerDirectory(ts), { recursive: true, force: true });
173
404
  }
174
405
  /** Throttled, and never the reason a call fails: pruning is housekeeping. */
175
406
  pruneNowAndThen() {
@@ -184,6 +415,182 @@ export class SlackConversation {
184
415
  // A file another process removed first, or one still being written.
185
416
  }
186
417
  }
418
+ async postText(threadTs, text) {
419
+ const { body } = await this.api.call("chat.postMessage", { channel: this.options.channel, thread_ts: threadTs, text });
420
+ return String(body.ts);
421
+ }
422
+ async postAskFallback(threadTs, text, ask) {
423
+ const { body } = await this.api.call("chat.postMessage", {
424
+ channel: this.options.channel,
425
+ thread_ts: threadTs,
426
+ text: renderAsk(text, ask, true),
427
+ metadata: askMetadata(ASK_FALLBACK_EVENT, ask),
428
+ });
429
+ return String(body.ts);
430
+ }
431
+ /** Reconstruct answers from Slack every time; no accepted-answer cache survives a process. */
432
+ async askedReplies(threadTs, after, messages) {
433
+ const asks = messages.map(readAsk).filter((ask) => ask !== undefined);
434
+ const taken = new Set();
435
+ const replies = [];
436
+ for (const entry of asks.sort((a, b) => tsToMicros(a.message.ts) - tsToMicros(b.message.ts))) {
437
+ const remembered = this.rememberedAnswer(threadTs, entry.ask.id);
438
+ // Re-read an accepted ask too: its decision is immutable, but a later
439
+ // complete, different mark still deserves the once-only acknowledgement.
440
+ // Marks Slack cannot fully list block a first answer, never a saved
441
+ // one: the remembered answer is replayed and nothing new is read.
442
+ const verdict = await this.answerOf(entry.message, entry.ask).catch((error) => {
443
+ if (remembered)
444
+ return { ambiguous: false, observed: [] };
445
+ throw error;
446
+ });
447
+ const accepted = await this.acceptedAnswer(threadTs, messages, entry, remembered, verdict);
448
+ if (!accepted)
449
+ continue;
450
+ const chosen = accepted.chosen;
451
+ // Another copy of the ask carries the answer: this one is replayed by
452
+ // nothing, and a complete mark on it is a later answer, told once.
453
+ if (accepted.messageTs !== entry.message.ts) {
454
+ if (verdict.chosen || verdict.ambiguous)
455
+ await this.acknowledgeTaken(threadTs, messages, entry.ask, chosen);
456
+ continue;
457
+ }
458
+ if (this.hasChangedAnswer(remembered, verdict))
459
+ await this.acknowledgeTaken(threadTs, messages, entry.ask, chosen);
460
+ if (taken.has(entry.ask.id)) {
461
+ await this.acknowledgeTaken(threadTs, messages, entry.ask, chosen);
462
+ continue;
463
+ }
464
+ taken.add(entry.ask.id);
465
+ // An answer saved before amy dated it carries its ask's time instead.
466
+ const acceptedAt = accepted.acceptedAt === undefined ? tsToMicros(accepted.messageTs) : instantToMicros(accepted.acceptedAt);
467
+ if (acceptedAt > after) {
468
+ replies.push({ at: microsToInstant(acceptedAt), author: this.options.operator, text: entry.ask.prompt, files: [], kind: "decision", answer: { ask: entry.ask.id, chosen } });
469
+ }
470
+ }
471
+ return replies;
472
+ }
473
+ /** The answer this ask holds after this look, if any: the remembered one, or the first complete one seen now. */
474
+ async acceptedAnswer(threadTs, messages, entry, remembered, verdict) {
475
+ if (verdict.ambiguous) {
476
+ await this.explainAmbiguity(threadTs, messages, entry.ask, verdict.observed);
477
+ // A later conflict cannot revoke the durable first answer: replay it
478
+ // so a look that previously parked still has the decision to apply.
479
+ if (remembered)
480
+ await this.acknowledgeTaken(threadTs, messages, entry.ask, remembered.chosen);
481
+ return remembered;
482
+ }
483
+ if (!verdict.chosen)
484
+ return remembered;
485
+ return remembered ?? this.rememberAnswer(threadTs, entry.ask.id, verdict.chosen, entry.message.ts);
486
+ }
487
+ /** The first complete answer is immutable once seen, even if its reaction changes later. */
488
+ rememberedAnswer(threadTs, askId) {
489
+ try {
490
+ const state = JSON.parse(fs.readFileSync(this.answerFile(threadTs, askId), "utf-8"));
491
+ if (!Array.isArray(state.chosen) || !state.chosen.every((chosen) => typeof chosen === "string") || typeof state.messageTs !== "string")
492
+ return undefined;
493
+ return { chosen: state.chosen, messageTs: state.messageTs, ...(typeof state.acceptedAt === "string" ? { acceptedAt: state.acceptedAt } : {}) };
494
+ }
495
+ catch (error) {
496
+ if (error.code === "ENOENT")
497
+ return undefined;
498
+ throw error;
499
+ }
500
+ }
501
+ /** Creates the decision once; a competing reader adopts the value written first. */
502
+ rememberAnswer(threadTs, askId, chosen, messageTs) {
503
+ const file = this.answerFile(threadTs, askId);
504
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
505
+ const staged = path.join(path.dirname(file), `.${randomUUID()}.partial`);
506
+ const handle = fs.openSync(staged, "wx", 0o600);
507
+ try {
508
+ // When amy first saw it, not when it asked: an ask posted before a
509
+ // look's since can still be answered after it.
510
+ fs.writeFileSync(handle, JSON.stringify({ chosen, messageTs, acceptedAt: this.clock().toISOString() }));
511
+ fs.fsyncSync(handle);
512
+ }
513
+ finally {
514
+ fs.closeSync(handle);
515
+ }
516
+ try {
517
+ fs.linkSync(staged, file);
518
+ }
519
+ catch (error) {
520
+ if (error.code !== "EEXIST")
521
+ throw error;
522
+ }
523
+ finally {
524
+ fs.rmSync(staged, { force: true });
525
+ }
526
+ return this.rememberedAnswer(threadTs, askId) ?? { chosen, messageTs, acceptedAt: this.clock().toISOString() };
527
+ }
528
+ /** The first ask amy posted in this thread, as microseconds, if it kept one. */
529
+ askedFrom(threadTs) {
530
+ try {
531
+ const ts = fs.readFileSync(path.join(this.answerDirectory(threadTs), "asked-from"), "utf-8").trim();
532
+ const micros = tsToMicros(ts);
533
+ return Number.isNaN(micros) ? undefined : micros;
534
+ }
535
+ catch (error) {
536
+ if (error.code === "ENOENT")
537
+ return undefined;
538
+ throw error;
539
+ }
540
+ }
541
+ /** Keeps the earliest ask's ts; a later ask leaves it where it is. */
542
+ rememberAskedFrom(threadTs, ts) {
543
+ const earlier = this.askedFrom(threadTs);
544
+ if (earlier !== undefined && earlier <= tsToMicros(ts))
545
+ return;
546
+ fs.mkdirSync(this.answerDirectory(threadTs), { recursive: true, mode: 0o700 });
547
+ fs.writeFileSync(path.join(this.answerDirectory(threadTs), "asked-from"), ts, { mode: 0o600 });
548
+ }
549
+ answerFile(threadTs, askId) {
550
+ return path.join(this.answerDirectory(threadTs), `${createHash("sha256").update(askId).digest("hex")}.json`);
551
+ }
552
+ answerDirectory(threadTs) {
553
+ return path.join(this.options.directory, "answers", safeName(threadTs));
554
+ }
555
+ hasChangedAnswer(remembered, verdict) {
556
+ return remembered !== undefined && (verdict.ambiguous || (verdict.chosen !== undefined && !sameAnswers(remembered.chosen, verdict.chosen)));
557
+ }
558
+ async answerOf(message, ask) {
559
+ const names = reactionsFor(ask);
560
+ const reactions = await this.completeReactions(message, names);
561
+ if (names.some((name) => this.marked(reactions, name) === UNKNOWN))
562
+ throw new Error(`Slack did not identify every reaction on ask ${ask.id}`);
563
+ const marked = names.filter((name) => this.marked(reactions, name) === true);
564
+ if (ask.form === "approval") {
565
+ return { chosen: marked.length === 1 ? [marked[0] === "white_check_mark" ? "approve" : "reject"] : undefined, ambiguous: marked.length > 1, observed: marked };
566
+ }
567
+ const selected = KEYCAPS.slice(0, ask.answers.length).filter((name) => marked.includes(name));
568
+ if (ask.form === "choice" && selected.length > 1)
569
+ return { ambiguous: true, observed: selected };
570
+ if (ask.form === "choices" && !marked.includes(SUBMIT_REACTION))
571
+ return { ambiguous: false, observed: selected };
572
+ if (ask.form === "choice" && selected.length !== 1)
573
+ return { ambiguous: false, observed: selected };
574
+ return { chosen: selected.map((name) => ask.answers[KEYCAPS.indexOf(name)]), ambiguous: false, observed: selected };
575
+ }
576
+ async completeReactions(message, names) {
577
+ const listed = message.reactions ?? [];
578
+ if (names.some((name) => this.marked(listed, name) === UNKNOWN))
579
+ return this.allReactions(message.ts);
580
+ return listed;
581
+ }
582
+ async explainAmbiguity(threadTs, messages, ask, observed) {
583
+ if (hasAskEvent(messages, ASK_AMBIGUOUS_EVENT, ask.id))
584
+ return;
585
+ await this.api.call("chat.postMessage", { channel: this.options.channel, thread_ts: threadTs, text: `I saw conflicting answers for ${ask.id}: ${observed.join(", ")}. Please leave one answer.`, metadata: askMetadata(ASK_AMBIGUOUS_EVENT, ask) });
586
+ rememberAskEvent(messages, ASK_AMBIGUOUS_EVENT, ask);
587
+ }
588
+ async acknowledgeTaken(threadTs, messages, ask, chosen) {
589
+ if (hasAskEvent(messages, ASK_TAKEN_EVENT, ask.id))
590
+ return;
591
+ await this.api.call("chat.postMessage", { channel: this.options.channel, thread_ts: threadTs, text: `Amy already took ${ask.id}: ${chosen.join(", ")}.`, metadata: askMetadata(ASK_TAKEN_EVENT, ask) });
592
+ rememberAskEvent(messages, ASK_TAKEN_EVENT, ask);
593
+ }
187
594
  async channelChecks(scopes) {
188
595
  const label = `slack channel ${this.options.channel}`;
189
596
  try {
@@ -203,6 +610,8 @@ export class SlackConversation {
203
610
  isPrivate ? "groups:read" : "channels:read",
204
611
  isPrivate ? "groups:history" : "channels:history",
205
612
  "files:read",
613
+ "reactions:write",
614
+ "reactions:read",
206
615
  ];
207
616
  const missing = needed.filter((scope) => !scopes.includes(scope));
208
617
  checks.push({
@@ -222,7 +631,7 @@ export class SlackConversation {
222
631
  */
223
632
  async startThread(workId, title) {
224
633
  const channel = this.options.channel;
225
- const ts = await this.threads.open(workId, channel, async (recoverSince) => {
634
+ const ts = await this.threadMap.open(workId, channel, async (recoverSince) => {
226
635
  const recovered = recoverSince === undefined ? undefined : await this.findRoot(workId, recoverSince);
227
636
  if (recovered)
228
637
  return { ts: recovered, posted: false };
@@ -258,9 +667,16 @@ export class SlackConversation {
258
667
  message.metadata.event_payload?.work_id === workId);
259
668
  return root?.ts;
260
669
  }
261
- /** Every message in the thread after `afterMicros`, across pages. */
262
- threadMessages(ts, afterMicros) {
263
- return this.pages("conversations.replies", { channel: this.options.channel, ts, oldest: microsToTs(afterMicros) });
670
+ /** Every message in the thread after `afterMicros`, or from it on when `inclusive`: Slack leaves the bound out by default. */
671
+ async threadMessages(ts, afterMicros, inclusive = false) {
672
+ // Work and chat consume Slack's same channel-wide replies allowance.
673
+ // Every request records it, so the next scheduled chat read never races a
674
+ // work-thread request under a one-call-per-minute installation.
675
+ writeJsonFile(this.repliesQuota(), { at: this.clock().getTime() });
676
+ return this.pages("conversations.replies", {
677
+ channel: this.options.channel, ts, oldest: microsToTs(afterMicros), include_all_metadata: "true",
678
+ ...(inclusive ? { inclusive: "true" } : {}),
679
+ });
264
680
  }
265
681
  async pages(method, args) {
266
682
  const messages = [];
@@ -273,6 +689,56 @@ export class SlackConversation {
273
689
  } while (cursor);
274
690
  return messages;
275
691
  }
692
+ /**
693
+ * What the operator marked their reply as, by their own reaction on it and
694
+ * nobody else's — never by its text. Both marks read as the decision, the
695
+ * one that moves the work, because a person who put both meant to be done.
696
+ * Re-read on every look, so marking a reply after it was answered counts.
697
+ *
698
+ * Slack may list only some of a reaction's users while `count` holds them
699
+ * all. Only a mark whose answer could still change the kind is asked again
700
+ * in full, and a reply the operator may have marked is never read as
701
+ * unmarked: if the full list still cannot say, the read fails and says why.
702
+ */
703
+ async kindOf(message) {
704
+ const listed = this.kindFrom(message.reactions ?? []);
705
+ if (listed !== UNKNOWN)
706
+ return listed;
707
+ const full = this.kindFrom(await this.allReactions(message.ts));
708
+ if (full !== UNKNOWN)
709
+ return full;
710
+ throw new Error(`cannot tell whether the operator marked reply ${message.ts}: Slack lists only part of who reacted to it`);
711
+ }
712
+ /** The kind these reactions say, or UNKNOWN when a list too short to tell could change it. */
713
+ kindFrom(reactions) {
714
+ const marks = this.options.marks ?? DEFAULT_MARKS;
715
+ const decision = this.marked(reactions, marks.decision);
716
+ if (decision === true)
717
+ return "decision";
718
+ const discussion = this.marked(reactions, marks.discussion);
719
+ if (decision === UNKNOWN || discussion === UNKNOWN)
720
+ return UNKNOWN;
721
+ return discussion ? "discussion" : undefined;
722
+ }
723
+ /** Whether the operator put this reaction on, or UNKNOWN when Slack's list is short and leaves them out. */
724
+ marked(reactions, name) {
725
+ const reaction = reactions.find((candidate) => candidate.name === name);
726
+ if (!reaction)
727
+ return false;
728
+ const users = reaction.users ?? [];
729
+ if (users.includes(this.options.operator))
730
+ return true;
731
+ return (reaction.count ?? users.length) > users.length ? UNKNOWN : false;
732
+ }
733
+ async allReactions(ts) {
734
+ try {
735
+ const { body } = await this.api.call("reactions.get", { channel: this.options.channel, timestamp: ts, full: "true" });
736
+ return (body.message?.reactions ?? []);
737
+ }
738
+ catch (error) {
739
+ throw new Error(`cannot tell whether the operator marked reply ${ts}: ${messageOf(error)}`, { cause: error });
740
+ }
741
+ }
276
742
  /** By author id, never by a marker in the text. */
277
743
  isOperatorAnswer(message, threadTs, bot) {
278
744
  if (!message.ts || message.ts === threadTs)
@@ -349,6 +815,51 @@ export class SlackConversation {
349
815
  return target;
350
816
  }
351
817
  }
818
+ function askMetadata(event, ask) {
819
+ return JSON.stringify({ event_type: event, event_payload: { ask } });
820
+ }
821
+ function readAsk(message) {
822
+ if (message.metadata?.event_type !== ASK_EVENT || !message.ts)
823
+ return undefined;
824
+ const ask = message.metadata.event_payload?.ask;
825
+ if (!isAsk(ask))
826
+ return undefined;
827
+ return { message, ask };
828
+ }
829
+ function isAsk(value) {
830
+ if (!value || typeof value !== "object")
831
+ return false;
832
+ const ask = value;
833
+ if (typeof ask.id !== "string" || typeof ask.prompt !== "string" || !Array.isArray(ask.answers))
834
+ return false;
835
+ if (ask.form === "approval")
836
+ return ask.answers.length === 2 && ask.answers[0] === "approve" && ask.answers[1] === "reject";
837
+ return ask.form === "choice" || ask.form === "choices";
838
+ }
839
+ function hasAskEvent(messages, event, askId) {
840
+ return messages.some((message) => message.metadata?.event_type === event && readMetadataAskId(message) === askId);
841
+ }
842
+ /** Make a successful notice visible to duplicate asks in this same poll. */
843
+ function rememberAskEvent(messages, event, ask) {
844
+ messages.push({ metadata: { event_type: event, event_payload: { ask } } });
845
+ }
846
+ function readMetadataAskId(message) {
847
+ const ask = message.metadata?.event_payload?.ask;
848
+ return ask && typeof ask === "object" && typeof ask.id === "string" ? ask.id : undefined;
849
+ }
850
+ function validAsk(ask) {
851
+ return ask.answers.length >= 2 && ask.answers.length <= 10 && ask.answers.every((answer) => answer.trim().length > 0 && answer.length <= 64) && new Set(ask.answers).size === ask.answers.length;
852
+ }
853
+ function reactionsFor(ask) {
854
+ return ask.form === "approval" ? APPROVAL_REACTIONS : ask.form === "choice" ? KEYCAPS.slice(0, ask.answers.length) : [...KEYCAPS.slice(0, ask.answers.length), SUBMIT_REACTION];
855
+ }
856
+ function sameAnswers(left, right) {
857
+ return left.length === right.length && left.every((answer, index) => answer === right[index]);
858
+ }
859
+ function renderAsk(text, ask, fallback) {
860
+ const answers = ask.answers.map((answer, index) => `${fallback ? `${index + 1}.` : ask.form === "approval" ? (index === 0 ? "✅" : "❌") : `${index + 1}️⃣`} ${answer}`);
861
+ return [text === ask.prompt ? "" : text, ask.prompt, ...answers].filter(Boolean).join("\n");
862
+ }
352
863
  /**
353
864
  * Whether a file was already downloaded, marking it used if so: retention
354
865
  * counts from the last use. One pruned between the look and the touch is a
@@ -410,6 +921,33 @@ function removeIfStale(file, cutoff) {
410
921
  fs.rmSync(aside, { force: true });
411
922
  return true;
412
923
  }
924
+ function jsonIn(directory) {
925
+ try {
926
+ return fs.readdirSync(directory).filter((name) => name.endsWith(".json"));
927
+ }
928
+ catch (error) {
929
+ if (error.code === "ENOENT")
930
+ return [];
931
+ throw error;
932
+ }
933
+ }
934
+ function readJson(file) {
935
+ try {
936
+ return JSON.parse(fs.readFileSync(file, "utf-8"));
937
+ }
938
+ catch (error) {
939
+ if (error.code === "ENOENT" || error instanceof SyntaxError)
940
+ return undefined;
941
+ throw error;
942
+ }
943
+ }
944
+ /** Written aside and renamed, so a reader never sees half of it. */
945
+ function writeJsonFile(file, value) {
946
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
947
+ const partial = path.join(path.dirname(file), `.${randomUUID()}.partial`);
948
+ fs.writeFileSync(partial, `${JSON.stringify(value)}\n`, { mode: 0o600 });
949
+ fs.renameSync(partial, file);
950
+ }
413
951
  function mtimeOf(file) {
414
952
  try {
415
953
  return fs.statSync(file).mtimeMs;