allaboard 0.0.0-stage → 0.4.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.
Files changed (141) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +43 -2
  3. package/dist/agent/claude/backend.js +124 -0
  4. package/dist/agent/claude/capabilities.js +30 -0
  5. package/dist/agent/claude/chrome.js +42 -0
  6. package/dist/agent/claude/hooks.js +37 -0
  7. package/dist/agent/claude/info.js +61 -0
  8. package/dist/agent/claude/listing.js +230 -0
  9. package/dist/agent/claude/module.js +82 -0
  10. package/dist/agent/claude/prompt.js +24 -0
  11. package/dist/agent/claude/record.js +9 -0
  12. package/dist/agent/claude/requests.js +122 -0
  13. package/dist/agent/claude/session.js +441 -0
  14. package/dist/agent/claude/translate.js +581 -0
  15. package/dist/agent/claude/trust.js +576 -0
  16. package/dist/agent/claude/usage-probe.js +103 -0
  17. package/dist/agent/claude/usage.js +110 -0
  18. package/dist/agent/claude/wire.js +22 -0
  19. package/dist/agent/seam.js +21 -0
  20. package/dist/chat/seam.js +10 -0
  21. package/dist/chat/slack/app/answers.js +209 -0
  22. package/dist/chat/slack/app/app.js +262 -0
  23. package/dist/chat/slack/app/clip.js +191 -0
  24. package/dist/chat/slack/app/guards.js +162 -0
  25. package/dist/chat/slack/app/home-controls.js +91 -0
  26. package/dist/chat/slack/app/messages.js +181 -0
  27. package/dist/chat/slack/app/open.js +392 -0
  28. package/dist/chat/slack/app/order.js +37 -0
  29. package/dist/chat/slack/app/requests.js +482 -0
  30. package/dist/chat/slack/app/resume-bind.js +71 -0
  31. package/dist/chat/slack/app/wire.js +83 -0
  32. package/dist/chat/slack/attachments.js +348 -0
  33. package/dist/chat/slack/bind.js +67 -0
  34. package/dist/chat/slack/channel.js +67 -0
  35. package/dist/chat/slack/channels.js +43 -0
  36. package/dist/chat/slack/delete.js +278 -0
  37. package/dist/chat/slack/footer.js +43 -0
  38. package/dist/chat/slack/hold.js +37 -0
  39. package/dist/chat/slack/home.js +1069 -0
  40. package/dist/chat/slack/module.js +174 -0
  41. package/dist/chat/slack/notice.js +64 -0
  42. package/dist/chat/slack/openfile/modal.js +362 -0
  43. package/dist/chat/slack/openfile/read.js +29 -0
  44. package/dist/chat/slack/quiet-logger.js +52 -0
  45. package/dist/chat/slack/repair.js +123 -0
  46. package/dist/chat/slack/reply/blocks.js +372 -0
  47. package/dist/chat/slack/reply/chars.js +59 -0
  48. package/dist/chat/slack/reply/clients.js +171 -0
  49. package/dist/chat/slack/reply/errors.js +125 -0
  50. package/dist/chat/slack/reply/escape.js +25 -0
  51. package/dist/chat/slack/reply/limiter.js +86 -0
  52. package/dist/chat/slack/reply/markdown.js +210 -0
  53. package/dist/chat/slack/reply/places.js +262 -0
  54. package/dist/chat/slack/reply/sinks.js +2403 -0
  55. package/dist/chat/slack/reply/status.js +433 -0
  56. package/dist/chat/slack/reply/tasks.js +11 -0
  57. package/dist/chat/slack/requests.js +552 -0
  58. package/dist/chat/slack/resume.js +127 -0
  59. package/dist/chat/slack/setup.js +114 -0
  60. package/dist/chat/slack/texts.js +185 -0
  61. package/dist/chat/slack/thread.js +537 -0
  62. package/dist/chat/slack/update.js +71 -0
  63. package/dist/chat/slack/voice.js +84 -0
  64. package/dist/cli.js +326 -0
  65. package/dist/clock.js +21 -0
  66. package/dist/core/cleanup.js +145 -0
  67. package/dist/core/commands.js +172 -0
  68. package/dist/core/config.js +174 -0
  69. package/dist/core/files/changed.js +125 -0
  70. package/dist/core/files/listing.js +382 -0
  71. package/dist/core/files/paths.js +9 -0
  72. package/dist/core/files/read.js +191 -0
  73. package/dist/core/files/waiting.js +34 -0
  74. package/dist/core/flows/answers.js +133 -0
  75. package/dist/core/flows/ask-owner.js +95 -0
  76. package/dist/core/flows/asking.js +14 -0
  77. package/dist/core/flows/bind.js +95 -0
  78. package/dist/core/flows/contract.js +1 -0
  79. package/dist/core/flows/flows.js +35 -0
  80. package/dist/core/flows/hold.js +22 -0
  81. package/dist/core/flows/inbound.js +24 -0
  82. package/dist/core/flows/lists.js +21 -0
  83. package/dist/core/flows/messages.js +293 -0
  84. package/dist/core/flows/notices.js +51 -0
  85. package/dist/core/flows/repair.js +106 -0
  86. package/dist/core/flows/resume.js +163 -0
  87. package/dist/core/flows/setup.js +130 -0
  88. package/dist/core/flows/status.js +98 -0
  89. package/dist/core/flows/talk.js +207 -0
  90. package/dist/core/flows/update.js +370 -0
  91. package/dist/core/flows/words.js +171 -0
  92. package/dist/core/folders.js +93 -0
  93. package/dist/core/footer.js +313 -0
  94. package/dist/core/guide.js +50 -0
  95. package/dist/core/hold.js +126 -0
  96. package/dist/core/instance.js +46 -0
  97. package/dist/core/lock.js +412 -0
  98. package/dist/core/models.js +10 -0
  99. package/dist/core/paths.js +75 -0
  100. package/dist/core/port/file-flows.js +1 -0
  101. package/dist/core/port/home.js +1 -0
  102. package/dist/core/port/requests.js +1 -0
  103. package/dist/core/port/threads.js +1 -0
  104. package/dist/core/port/updates.js +1 -0
  105. package/dist/core/port.js +17 -0
  106. package/dist/core/reply/fold.js +175 -0
  107. package/dist/core/reply/previews.js +258 -0
  108. package/dist/core/reply/renderer.js +846 -0
  109. package/dist/core/reply/tail.js +24 -0
  110. package/dist/core/reply/words.js +32 -0
  111. package/dist/core/requests.js +158 -0
  112. package/dist/core/sessions/constants.js +61 -0
  113. package/dist/core/sessions/deps.js +1 -0
  114. package/dist/core/sessions/directory.js +86 -0
  115. package/dist/core/sessions/errors.js +79 -0
  116. package/dist/core/sessions/manager.js +378 -0
  117. package/dist/core/sessions/session.js +3297 -0
  118. package/dist/core/sessions/tasks.js +266 -0
  119. package/dist/core/sessions/turn.js +45 -0
  120. package/dist/core/setup.js +28 -0
  121. package/dist/core/state.js +724 -0
  122. package/dist/core/texts.js +451 -0
  123. package/dist/core/updates.js +332 -0
  124. package/dist/daemon.js +566 -0
  125. package/dist/host/npm.js +134 -0
  126. package/dist/host/program.js +49 -0
  127. package/dist/host/run.js +52 -0
  128. package/dist/host/service/launchd.js +385 -0
  129. package/dist/host/service/listing.js +42 -0
  130. package/dist/host/service/seam.js +1 -0
  131. package/dist/log.js +37 -0
  132. package/dist/main.js +29 -0
  133. package/dist/node-version.js +40 -0
  134. package/dist/registry.js +12 -0
  135. package/dist/semver.js +53 -0
  136. package/dist/text.js +89 -0
  137. package/dist/updates-host.js +113 -0
  138. package/dist/values.js +30 -0
  139. package/dist/version.js +54 -0
  140. package/package.json +32 -4
  141. package/slack-app-manifest.json +50 -0
@@ -0,0 +1,482 @@
1
+ /**
2
+ * The clicks and forms that answer the questions the daemon puts to the owner, as Slack sends
3
+ * them: a tool's permission request and the agent's own questions, the same-folder hold and the
4
+ * session setup. What is asked, which choices exist and what follows an answer are the core's
5
+ * (`core/flows/`); each listener checks the owner and the channel, reads its payload and tells
6
+ * the core through `ChatInbound.answer`. The controls that only redraw a message stay here.
7
+ */
8
+ import { fill, texts } from "../../../core/port.js";
9
+ import { HOLD_CONTINUE } from "../hold.js";
10
+ import { describe } from "../reply/errors.js";
11
+ import { APPROVAL_ALLOW, absorb, draftAnswers, firstIncomplete, isAnswered, loadIds, newDraft, QUESTION_EDIT, QUESTION_FORM, QUESTION_SKIP, questionView, refusedView, } from "../requests.js";
12
+ import { readChoice, readSelection, setupBlocks } from "../setup.js";
13
+ import * as slackTexts from "../texts.js";
14
+ import { isCancelled, logger } from "./answers.js";
15
+ import { interactionActor, isOwner } from "./guards.js";
16
+ import { clickThread, firstAction, messageTs, record, str, text, } from "./wire.js";
17
+ /** The block ids of the input blocks in `blocks`, in order; none for anything but a list. */
18
+ function inputIds(blocks) {
19
+ if (!Array.isArray(blocks))
20
+ return [];
21
+ return blocks.flatMap((block) => {
22
+ const entry = record(block);
23
+ return entry.type === "input" ? [str(entry.block_id)] : [];
24
+ });
25
+ }
26
+ export class Requests {
27
+ parts;
28
+ answers;
29
+ /**
30
+ * What the owner filled in each open question form, by request id: kept here, not in the
31
+ * view's `private_metadata` (which holds the request's ids, the form's generation and its
32
+ * page), so Other and Notes are bound by no
33
+ * Slack limit and Answer reopens a closed form with the answers kept. An entry goes when its
34
+ * request is answered, skipped or found gone, at the next Answer for any request that ended
35
+ * another way, and when the app closes.
36
+ */
37
+ drafts = new Map();
38
+ constructor(parts, answers) {
39
+ this.parts = parts;
40
+ this.answers = answers;
41
+ }
42
+ /** Drops the drafts of requests that no longer wait for an answer. */
43
+ sweep() {
44
+ for (const [approvalId, draft] of this.drafts) {
45
+ if (this.parts.approvals.questions(approvalId, draft.channelId, draft.threadTs) === null) {
46
+ this.drafts.delete(approvalId);
47
+ }
48
+ }
49
+ }
50
+ /** Gives up the drafts: the daemon is stopping. */
51
+ close() {
52
+ this.drafts.clear();
53
+ }
54
+ // Issue #142: the facts that tell apart the paths that answer `texts.HOLD_GONE`, for the click
55
+ // that only redraws (a model picked); the core logs those of the answers it refuses. Ids and
56
+ // flags only: the click's text and the setup's choice never reach the log.
57
+ logRefusedClick(path, body, facts) {
58
+ const told = Object.entries(facts)
59
+ .map(([name, value]) => `${name}=${value ? "True" : "False"}`)
60
+ .join(", ");
61
+ logger.info(`refused a click (${path}): action ${str(firstAction(body).action_id)} in ` +
62
+ `${str(record(body.channel).id)}/${clickThread(body)} on message ` +
63
+ `${str(record(body.message).ts)}; ${told}`);
64
+ }
65
+ /** Send anyway, or Don't send: the two buttons of the same-folder hold. */
66
+ onHoldDecision = async (ack, body) => {
67
+ await ack();
68
+ const [user, team] = interactionActor(body);
69
+ const channel = text(record(body.channel).id);
70
+ const threadTs = clickThread(body);
71
+ if (!(await this.answers.admitted(user, team, channel, threadTs)) || channel === null)
72
+ return;
73
+ const action = firstAction(body);
74
+ await this.parts.inbound.answer({ channel, thread: threadTs }, str(action.value), { type: "hold", send: action.action_id === HOLD_CONTINUE },
75
+ // A click that names no message is still answered: nothing is removed for it.
76
+ text(record(body.message).ts));
77
+ };
78
+ /** Start, in a session's setup: the controls as they stand ride on the click. */
79
+ onSetupStart = async (ack, body) => {
80
+ await ack();
81
+ const [user, team] = interactionActor(body);
82
+ const channel = text(record(body.channel).id);
83
+ const threadTs = clickThread(body);
84
+ if (!(await this.answers.admitted(user, team, channel, threadTs)) || channel === null)
85
+ return;
86
+ await this.parts.inbound.answer({ channel, thread: threadTs }, str(firstAction(body).value), { type: "setup", selection: readSelection(record(body.state).values) }, text(record(body.message).ts));
87
+ };
88
+ /**
89
+ * A new model changes which efforts exist: the message is rewritten with the levels of the
90
+ * model now chosen, keeping the rest. An edit never rings a phone.
91
+ */
92
+ onSetupModel = async (ack, body) => {
93
+ await ack();
94
+ const { holds, limiter, slack } = this.parts;
95
+ const [user, team] = interactionActor(body);
96
+ const channel = text(record(body.channel).id);
97
+ const threadTs = clickThread(body);
98
+ if (!(await this.answers.admitted(user, team, channel, threadTs)) || channel === null)
99
+ return;
100
+ const shownAt = messageTs(body);
101
+ const setupId = holds.atMessage(channel, threadTs, shownAt);
102
+ if (setupId === null && holds.decidedAt(channel, threadTs, shownAt)) {
103
+ // Picked just before Start and handled once Start decided the setup (issue #142): what
104
+ // ends the apply (the summary, `Not sent.` or the error) says what happened.
105
+ this.logRefusedClick("setup model, decided", body, { open_at_message: false, held: true });
106
+ return;
107
+ }
108
+ if (setupId === null) {
109
+ this.logRefusedClick("setup model, no open setup", body, {
110
+ open_at_message: false,
111
+ held: false,
112
+ });
113
+ await this.answers.tellOwner(channel, threadTs, texts.HOLD_GONE);
114
+ return;
115
+ }
116
+ const draw = this.parts.chat.drawnSetup(setupId);
117
+ if (draw === undefined) {
118
+ // Not kept (a long-forgotten request): the controls keep what they showed.
119
+ this.logRefusedClick("setup model, draw not kept", body, { open_at_message: true });
120
+ return;
121
+ }
122
+ const choice = readChoice(record(body.state).values, draw.models);
123
+ try {
124
+ await limiter.acquire();
125
+ // The wait above can outlast Start: a decided setup now shows its summary, which this
126
+ // edit must not overwrite.
127
+ if (holds.atMessage(channel, threadTs, shownAt) !== setupId || this.answers.stopped)
128
+ return;
129
+ await slack.chat.update({
130
+ channel,
131
+ ts: shownAt,
132
+ text: texts.SETUP_FALLBACK,
133
+ blocks: setupBlocks(setupId, draw, choice),
134
+ });
135
+ }
136
+ catch (error) {
137
+ if (isCancelled(error))
138
+ throw error;
139
+ // The controls keep what they showed; Start still reads the model and the effort.
140
+ logger.warning(`could not update a setup message in ${channel}: ${describe(error)}`);
141
+ }
142
+ };
143
+ /** An effort or bypass change needs nothing: Start reads every control's state. */
144
+ onSetupEdit = async (ack) => {
145
+ await ack();
146
+ };
147
+ /** Approve, Deny or Skip on a request of the agent's. */
148
+ onDecision = async (ack, body) => {
149
+ await ack();
150
+ const [user, team] = interactionActor(body);
151
+ const channel = text(record(body.channel).id);
152
+ const threadTs = clickThread(body);
153
+ if (!(await this.answers.admitted(user, team, channel, threadTs)) || channel === null)
154
+ return;
155
+ const action = firstAction(body);
156
+ const answer = action.action_id === QUESTION_SKIP
157
+ ? { type: "question", answers: null, notes: {} }
158
+ : { type: "permission", allow: action.action_id === APPROVAL_ALLOW };
159
+ if (action.action_id === QUESTION_SKIP)
160
+ this.drafts.delete(str(action.value));
161
+ await this.parts.inbound.answer({ channel, thread: threadTs }, str(action.value), answer, text(record(body.message).ts));
162
+ };
163
+ /**
164
+ * A change in the question form, dispatched by Slack with the form's state (block reference,
165
+ * input block `dispatch_action` and `dispatch_action_config`; block_actions payload reference;
166
+ * `views.update`, all read 2026-10-16). Every field reports, so the draft holds what is on
167
+ * screen when the form is closed with Close. Only a change of the choice redraws, with Other's
168
+ * field and Notes as the new choice needs them, in one `views.update`; typing never does. `hash`
169
+ * makes a stale payload a `hash_conflict`, which is ignored. Submit reads the state again. Only
170
+ * the newest form of a request counts (`Draft.generation`) and only the page it shows now
171
+ * (`FormIds`); anything else is ignored.
172
+ */
173
+ onQuestionLive = async (ack, body, redraw) => {
174
+ await ack();
175
+ const { approvals, identity, slack } = this.parts;
176
+ const [user, team] = interactionActor(body);
177
+ // (sync) The owner and the workspace, as on Next; the channel is checked at Submit.
178
+ if (!isOwner(identity, user, team)) {
179
+ logger.info("ignored an inbound event from someone other than the owner");
180
+ return;
181
+ }
182
+ const view = record(body.view);
183
+ if (view.callback_id !== QUESTION_FORM)
184
+ return;
185
+ let ids;
186
+ try {
187
+ ids = loadIds(str(view.private_metadata));
188
+ }
189
+ catch {
190
+ return;
191
+ }
192
+ const questions = approvals.questions(ids.approvalId, ids.channelId, ids.threadTs);
193
+ if (questions === null) {
194
+ this.drafts.delete(ids.approvalId);
195
+ return;
196
+ }
197
+ // Only the newest form of the request counts, and only the page it shows now: a late change
198
+ // from an older form, or from a page Next has left, writes nothing.
199
+ const kept = this.drafts.get(ids.approvalId);
200
+ if (kept === undefined || kept.generation !== ids.generation || kept.active !== ids.active) {
201
+ return;
202
+ }
203
+ // (sync) Kept before any await: a payload handled next finds what this one saw.
204
+ const draft = absorb(kept, record(view.state).values);
205
+ this.drafts.set(ids.approvalId, draft);
206
+ const viewId = text(view.id);
207
+ if (!redraw || viewId === null || this.answers.stopped)
208
+ return;
209
+ const hash = text(view.hash);
210
+ try {
211
+ await slack.views.update({
212
+ view_id: viewId,
213
+ ...(hash === null ? {} : { hash }),
214
+ view: questionView(draft, questions, this.parts.names),
215
+ });
216
+ }
217
+ catch (error) {
218
+ if (isCancelled(error))
219
+ throw error;
220
+ // hash_conflict (a newer payload moved the form on) or not_found (closed): Submit reads
221
+ // the state as it stands.
222
+ logger.warning(`could not update the question form: ${describe(error)}`);
223
+ }
224
+ };
225
+ /** An option picked, or a box ticked, in the question form: the form is drawn again. */
226
+ onQuestionChoice = (ack, body) => this.onQuestionLive(ack, body, true);
227
+ /** A character typed under Other or in Notes in the question form: the draft alone changes. */
228
+ onQuestionTyping = (ack, body) => this.onQuestionLive(ack, body, false);
229
+ /**
230
+ * Edit, on the review page of the question form: that question's page, with its answers as
231
+ * stored (`views.update` with the payload's view id and hash, block_actions payload and
232
+ * `views.update` references, read 2026-10-16). The review page has no field, so nothing is
233
+ * absorbed or validated. It checks the owner and the workspace like any inbound path, follows
234
+ * the newest-form rule of `onQuestionLive`, acts only from the review page and never reaches
235
+ * the core. The draft's page moves before the call so that a late change from the page left is
236
+ * ignored; when Slack refuses the update (`hash_conflict`: the form moved on, `not_found`: it
237
+ * closed) the draft is put back, unless Submit answers sent it meanwhile.
238
+ */
239
+ onQuestionEdit = async (ack, body) => {
240
+ await ack();
241
+ const { approvals, identity, slack } = this.parts;
242
+ const [user, team] = interactionActor(body);
243
+ // (sync) The owner and the workspace, as on Next; the channel is checked at Submit.
244
+ if (!isOwner(identity, user, team)) {
245
+ logger.info("ignored an inbound event from someone other than the owner");
246
+ return;
247
+ }
248
+ const view = record(body.view);
249
+ if (view.callback_id !== QUESTION_FORM)
250
+ return;
251
+ let ids;
252
+ try {
253
+ ids = loadIds(str(view.private_metadata));
254
+ }
255
+ catch {
256
+ return;
257
+ }
258
+ const questions = approvals.questions(ids.approvalId, ids.channelId, ids.threadTs);
259
+ if (questions === null) {
260
+ this.drafts.delete(ids.approvalId);
261
+ return;
262
+ }
263
+ const target = Number(QUESTION_EDIT.exec(str(firstAction(body).action_id))?.[1] ?? Number.NaN);
264
+ const kept = this.drafts.get(ids.approvalId);
265
+ if (kept === undefined ||
266
+ kept.generation !== ids.generation ||
267
+ kept.active !== ids.active ||
268
+ questions.length < 2 ||
269
+ ids.active !== questions.length ||
270
+ !Number.isInteger(target) ||
271
+ target >= questions.length) {
272
+ return;
273
+ }
274
+ // (sync) Kept before any await: a payload handled next finds what this one saw.
275
+ const moved = { ...kept, active: target };
276
+ this.drafts.set(ids.approvalId, moved);
277
+ const viewId = text(view.id);
278
+ if (viewId === null || this.answers.stopped)
279
+ return;
280
+ const hash = text(view.hash);
281
+ try {
282
+ await slack.views.update({
283
+ view_id: viewId,
284
+ ...(hash === null ? {} : { hash }),
285
+ view: questionView(moved, questions, this.parts.names),
286
+ });
287
+ }
288
+ catch (error) {
289
+ if (isCancelled(error))
290
+ throw error;
291
+ // The form on screen is still the review page.
292
+ if (this.drafts.get(ids.approvalId) === moved)
293
+ this.drafts.set(ids.approvalId, kept);
294
+ logger.warning(`could not open a question from the review page: ${describe(error)}`);
295
+ }
296
+ };
297
+ /**
298
+ * Answer, on a question of Claude's: opens the form, then checks the channel. The form shows
299
+ * the owner alone what the request already shows, and Submit checks the channel again before
300
+ * anything reaches the agent; a refused channel turns the open form into the reason.
301
+ */
302
+ onQuestionOpen = async (ack, body) => {
303
+ await ack();
304
+ const { approvals, guard, names, slack } = this.parts;
305
+ const [user, team] = interactionActor(body);
306
+ const channel = text(record(body.channel).id);
307
+ const threadTs = clickThread(body);
308
+ if (!this.answers.fromOwner(user, team, channel) || channel === null)
309
+ return;
310
+ const approvalId = str(firstAction(body).value);
311
+ const questions = approvals.questions(approvalId, channel, threadTs);
312
+ this.sweep();
313
+ if (questions === null) {
314
+ await this.answers.tellOwner(channel, threadTs, texts.APPROVAL_GONE);
315
+ return;
316
+ }
317
+ // A form closed with Close and opened again shows what was given, on the page it was left
318
+ // on. Every Answer opens a new generation, and the older forms of the request go stale.
319
+ const kept = this.drafts.get(approvalId) ?? newDraft(approvalId, channel, threadTs);
320
+ const draft = { ...kept, generation: kept.generation + 1 };
321
+ this.drafts.set(approvalId, draft);
322
+ // trigger_id lives 3 seconds: no network call before this one (#325).
323
+ const view = questionView(draft, questions, names);
324
+ let opened;
325
+ try {
326
+ const trigger = text(body.trigger_id);
327
+ if (trigger === null)
328
+ throw new TypeError("the click carries no trigger_id");
329
+ const answer = await slack.views.open({
330
+ trigger_id: trigger,
331
+ view: view,
332
+ });
333
+ opened = answer.view;
334
+ }
335
+ catch (error) {
336
+ if (isCancelled(error))
337
+ throw error;
338
+ // an expired trigger_id, say: the turn must not wait unseen. The form did not open, so
339
+ // the one still on screen stays the valid one.
340
+ if (this.drafts.get(approvalId) === draft)
341
+ this.drafts.set(approvalId, kept);
342
+ const message = fill(slackTexts.QUESTION_NOT_OPENED, { error: describe(error) });
343
+ await this.answers.tellOwner(channel, threadTs, message);
344
+ return;
345
+ }
346
+ if (this.answers.stopped)
347
+ return;
348
+ const reason = await guard.refusal(channel);
349
+ if (this.answers.stopped || reason === null)
350
+ return;
351
+ const told = fill(slackTexts.CHANNEL_REFUSED, { reason });
352
+ try {
353
+ if (opened?.id === undefined)
354
+ throw new TypeError("views.open answered no view id");
355
+ // The hash it was opened with: a Next clicked meanwhile is not overwritten.
356
+ await slack.views.update({
357
+ view_id: opened.id,
358
+ ...(opened.hash === undefined ? {} : { hash: opened.hash }),
359
+ view: refusedView(told, names),
360
+ });
361
+ }
362
+ catch (error) {
363
+ if (isCancelled(error))
364
+ throw error;
365
+ // Closed, or moved on: Submit refuses it, and the owner learns why here.
366
+ logger.warning(`could not show the refusal in the question form: ${describe(error)}`);
367
+ await this.answers.tellOwner(channel, threadTs, told);
368
+ }
369
+ };
370
+ /**
371
+ * Next, Review or Submit, in the question form. Slack wants the answer within 3 seconds, and a missing
372
+ * answer can only be shown in it: the checks before it make no network call. The channel is
373
+ * checked before acting.
374
+ */
375
+ onQuestionSubmit = async (ack, body) => {
376
+ const { approvals, identity } = this.parts;
377
+ const [user, team] = interactionActor(body);
378
+ const view = record(body.view);
379
+ let ids;
380
+ try {
381
+ ids = loadIds(str(view.private_metadata));
382
+ }
383
+ catch {
384
+ await ack();
385
+ return;
386
+ }
387
+ if (!isOwner(identity, user, team)) {
388
+ await ack();
389
+ logger.info("ignored an inbound event from someone other than the owner");
390
+ return;
391
+ }
392
+ const questions = approvals.questions(ids.approvalId, ids.channelId, ids.threadTs);
393
+ if (questions === null) {
394
+ this.drafts.delete(ids.approvalId);
395
+ await ack();
396
+ await this.answers.tellOwner(ids.channelId, ids.threadTs, texts.APPROVAL_GONE);
397
+ return;
398
+ }
399
+ const state = record(view.state).values;
400
+ // The page after the last question lists the answers (only a form of several has one).
401
+ const reviewing = questions.length > 1 && ids.active === questions.length;
402
+ const kept = this.drafts.get(ids.approvalId);
403
+ if (kept === undefined || kept.generation !== ids.generation) {
404
+ // An older form: it sends nothing, and the newest one is told apart from it. The review
405
+ // page has no field to carry an error, so it is replaced by the reason.
406
+ if (reviewing) {
407
+ await ack({
408
+ response_action: "update",
409
+ view: refusedView(slackTexts.QUESTION_STALE, this.parts.names),
410
+ });
411
+ return;
412
+ }
413
+ await ack({
414
+ response_action: "errors",
415
+ errors: { [`q${ids.active}`]: slackTexts.QUESTION_STALE },
416
+ });
417
+ return;
418
+ }
419
+ const at = ids.active;
420
+ // The review page has no field: its draft is the one kept.
421
+ const draft = reviewing ? { ...kept, active: at } : absorb({ ...kept, active: at }, state);
422
+ this.drafts.set(ids.approvalId, draft);
423
+ if (!reviewing) {
424
+ // A redraw Slack refused (`hash_conflict`) or one that has not landed yet leaves the form
425
+ // other than the draft draws; `view.blocks` of the payload lists the blocks on screen
426
+ // (docs.slack.dev, view interactions payload, read 2026-10-16: it does not say whether an
427
+ // empty input is listed in the state, so the state is not used for this). Only two
428
+ // differences put an answer at risk: Other chosen with no field to type in (Slack's
429
+ // required check could not run), and a note in the draft whose field the owner cannot
430
+ // see, though the form draws one. Any other difference sends what the draft holds: a
431
+ // field left on screen is read by `absorb`, and its text is used only while its choice is.
432
+ const drawn = questionView(draft, questions, this.parts.names);
433
+ const shown = inputIds(view.blocks);
434
+ const hidesOther = draft.other.has(at) && !shown.includes(`o${at}`);
435
+ const hidesNote = draft.notes.has(at) &&
436
+ inputIds(drawn.blocks).includes(`n${at}`) &&
437
+ !shown.includes(`n${at}`);
438
+ if (hidesOther || hidesNote) {
439
+ await ack({ response_action: "update", view: drawn });
440
+ return;
441
+ }
442
+ if (!isAnswered(draft, questions, at)) {
443
+ // Spaces alone in Other's field are empty once stripped, and Slack keeps them there.
444
+ const field = draft.other.has(at) ? `o${at}` : `q${at}`;
445
+ await ack({
446
+ response_action: "errors",
447
+ errors: { [field]: slackTexts.QUESTION_MISSING },
448
+ });
449
+ return;
450
+ }
451
+ }
452
+ // A form of several questions sends from its review page alone; every other page moves on.
453
+ // Before the review was reached Next goes to the next page; on the last page, and on every
454
+ // page once the review was reached, Review goes to it, or to the first question with no
455
+ // answer, which shows no error text.
456
+ const sends = reviewing || questions.length === 1;
457
+ const missing = sends ? firstIncomplete(draft, questions) : null;
458
+ if (!sends || missing !== null) {
459
+ const toReview = questions.length > 1 && !reviewing && (draft.reviewed || at === questions.length - 1);
460
+ // `missing` is set only when the draft changed under the review page or a one-question
461
+ // form; kept so that a draft cannot reach the agent incomplete.
462
+ const to = missing ?? (toReview ? (firstIncomplete(draft, questions) ?? questions.length) : at + 1);
463
+ const next = { ...draft, active: to, reviewed: draft.reviewed || to === questions.length };
464
+ this.drafts.set(ids.approvalId, next);
465
+ await ack({
466
+ response_action: "update",
467
+ view: questionView(next, questions, this.parts.names),
468
+ });
469
+ return;
470
+ }
471
+ await ack();
472
+ if (!(await this.answers.admitted(user, team, ids.channelId, ids.threadTs)))
473
+ return;
474
+ const given = draftAnswers(draft, questions);
475
+ if (given === null)
476
+ throw new Error("a draft with every question answered has no answers");
477
+ this.drafts.delete(ids.approvalId);
478
+ // The form is no message of the thread: the session that asked decides what becomes of the
479
+ // request message (`ThreadSession` keeps the answers in the reply, or in the request).
480
+ await this.parts.inbound.answer({ channel: ids.channelId, thread: ids.threadTs }, ids.approvalId, { type: "question", ...given }, null);
481
+ };
482
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * `!bind`, `!resume` and the offer of `!allaboard update`, typed or clicked, as the Slack provider
3
+ * hears them. What is offered,
4
+ * which rows a list holds and what follows a pick are the core's (`src/core/flows/lists.ts`);
5
+ * here a click is acknowledged, checked for the owner and the channel, and read into the pick
6
+ * the core is told of. The lists themselves are drawn by `SlackChannel.offer`.
7
+ */
8
+ import { parseResumeValue } from "../resume.js";
9
+ import { interactionActor } from "./guards.js";
10
+ import { clickThread, firstAction, messageTs, record, str, text, } from "./wire.js";
11
+ export class ResumeBind {
12
+ parts;
13
+ answers;
14
+ constructor(parts, answers) {
15
+ this.parts = parts;
16
+ this.answers = answers;
17
+ }
18
+ /** A Bind button of the folder list. */
19
+ onBind = async (ack, body) => {
20
+ await ack();
21
+ const [user, team] = interactionActor(body);
22
+ const channel = text(record(body.channel).id);
23
+ const threadTs = clickThread(body);
24
+ if (!(await this.answers.admitted(user, team, channel, threadTs)) || channel === null)
25
+ return;
26
+ // The button is not trusted: the core checks its folder as it checks a typed `!bind`.
27
+ await this.parts.inbound.picked({
28
+ type: "folder",
29
+ channel,
30
+ path: str(firstAction(body).value),
31
+ list: messageTs(body),
32
+ });
33
+ };
34
+ /** A Resume button of the session list. */
35
+ onResume = async (ack, body) => {
36
+ await ack();
37
+ const [user, team] = interactionActor(body);
38
+ const channel = text(record(body.channel).id);
39
+ const threadTs = clickThread(body);
40
+ if (!(await this.answers.admitted(user, team, channel, threadTs)) || channel === null)
41
+ return;
42
+ // A value of another shape is a list posted before the button carried its thread: the core
43
+ // is told the button named none. The session and the thread are checked again by the core.
44
+ const value = str(firstAction(body).value);
45
+ const parsed = parseResumeValue(value);
46
+ await this.parts.inbound.picked({
47
+ type: "session",
48
+ channel,
49
+ sessionId: parsed === null ? value : parsed[0],
50
+ thread: parsed === null ? null : { channel, thread: parsed[1] },
51
+ list: messageTs(body),
52
+ });
53
+ };
54
+ /** The Update button of the offer `!allaboard update` posted in a channel. */
55
+ onUpdate = async (ack, body) => {
56
+ await ack();
57
+ const [user, team] = interactionActor(body);
58
+ const channel = text(record(body.channel).id);
59
+ const threadTs = clickThread(body);
60
+ if (!(await this.answers.admitted(user, team, channel, threadTs)) || channel === null)
61
+ return;
62
+ // The version is the button's value, untrusted: the core installs it only when it is newer
63
+ // than the one that runs and the copy and the service allow it.
64
+ await this.parts.inbound.picked({
65
+ type: "update",
66
+ channel,
67
+ version: str(firstAction(body).value),
68
+ list: messageTs(body),
69
+ });
70
+ };
71
+ }
@@ -0,0 +1,83 @@
1
+ /** The object a field holds, or an empty one for anything else. */
2
+ export function record(value) {
3
+ return typeof value === "object" && value !== null && !Array.isArray(value)
4
+ ? value
5
+ : {};
6
+ }
7
+ /** The string a field holds; null for a missing one and for any other type. */
8
+ export function text(value) {
9
+ return typeof value === "string" ? value : null;
10
+ }
11
+ /** The list a field holds, or an empty one. */
12
+ export function items(value) {
13
+ return Array.isArray(value) ? value : [];
14
+ }
15
+ /**
16
+ * Python's `str(value)` for what a payload holds: a missing field reads `None`, which names no
17
+ * message, thread or request.
18
+ */
19
+ export function str(value) {
20
+ if (value === undefined || value === null)
21
+ return "None";
22
+ if (typeof value === "string")
23
+ return value;
24
+ if (typeof value === "boolean")
25
+ return value ? "True" : "False";
26
+ if (typeof value === "number" || typeof value === "bigint")
27
+ return String(value);
28
+ return JSON.stringify(value);
29
+ }
30
+ /** Whether a parsed JSON value is present: empty and zero are not. */
31
+ function truthy(value) {
32
+ if (value === undefined || value === null)
33
+ return false;
34
+ if (Array.isArray(value))
35
+ return value.length > 0;
36
+ if (typeof value === "object")
37
+ return Object.keys(value).length > 0;
38
+ return Boolean(value);
39
+ }
40
+ /** The first action of a click: `body["actions"][0]`, empty when the payload carries none. */
41
+ export function firstAction(body) {
42
+ return record(items(body.actions)[0]);
43
+ }
44
+ /**
45
+ * The ts of the message a button sits on: a click that names none throws, and the listener's
46
+ * failure is logged.
47
+ */
48
+ export function messageTs(body) {
49
+ const message = body.message;
50
+ if (typeof message !== "object" || message === null || !("ts" in message)) {
51
+ throw new TypeError("the click names no message");
52
+ }
53
+ return str(message.ts);
54
+ }
55
+ /**
56
+ * The thread a button or a form sits in: `container.thread_ts`, falling back to
57
+ * `message.thread_ts` then the message's own ts (a button is never trusted, so the thread it
58
+ * answers in is read the same way for every kind of click).
59
+ */
60
+ export function clickThread(body) {
61
+ const container = record(body.container);
62
+ const message = record(body.message);
63
+ const found = [container.thread_ts, message.thread_ts].find(truthy);
64
+ return str(found === undefined ? message.ts : found);
65
+ }
66
+ /**
67
+ * When an interaction happened, from its `action_ts`: what orders the updates it causes. `now`,
68
+ * the arrival time in seconds, for a payload that carries none.
69
+ *
70
+ * `inf`, `nan` and digits grouped with `_`, which Slack never writes, are a payload that carries
71
+ * no time.
72
+ */
73
+ export function actionKey(action, now) {
74
+ const raw = action.action_ts;
75
+ if (typeof raw === "number" && Number.isFinite(raw))
76
+ return raw;
77
+ if (typeof raw === "string" && raw.trim() !== "") {
78
+ const parsed = Number(raw);
79
+ if (Number.isFinite(parsed))
80
+ return parsed;
81
+ }
82
+ return now();
83
+ }