@open-everywhere/provider-slack 0.7.0 → 0.8.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 (127) hide show
  1. package/agent/access/slack-attachment-fetch.ts +2 -1
  2. package/agent/app-manifest.ts +15 -1
  3. package/agent/channels/slack-agent-session.ts +249 -0
  4. package/agent/channels/slack-attachments.ts +20 -11
  5. package/agent/channels/slack-commands.ts +172 -12
  6. package/agent/channels/slack-context.ts +0 -5
  7. package/agent/channels/slack-events.ts +103 -29
  8. package/agent/channels/slack-follow-ups.ts +148 -0
  9. package/agent/channels/slack-front.ts +232 -26
  10. package/agent/channels/slack-home.ts +9 -3
  11. package/agent/channels/slack-identity.ts +9 -1
  12. package/agent/channels/slack-memory-canvas.ts +192 -51
  13. package/agent/channels/slack-origin-codec.ts +9 -0
  14. package/agent/channels/slack-post-arm.ts +68 -11
  15. package/agent/channels/slack-read.ts +27 -2
  16. package/agent/channels/slack-reply-blocks.ts +180 -0
  17. package/agent/channels/slack-reply-feedback.ts +75 -0
  18. package/agent/channels/slack-shortcut.ts +1 -1
  19. package/agent/channels/slack-voice-clips.ts +82 -0
  20. package/agent/channels/slack-wake-backscroll.ts +0 -5
  21. package/agent/channels/slack.ts +74 -13
  22. package/agent/docs-generated.ts +1282 -2
  23. package/agent/ops.ts +14 -21
  24. package/agent/register.ts +13 -0
  25. package/agent/setup/credentials.ts +17 -0
  26. package/agent/setup/page.ts +6 -3
  27. package/agent/surface-parity.ts +2 -0
  28. package/dist/agent/access/slack-attachment-fetch.d.ts.map +1 -1
  29. package/dist/agent/access/slack-attachment-fetch.js.map +1 -1
  30. package/dist/agent/app-manifest.d.ts.map +1 -1
  31. package/dist/agent/app-manifest.js +15 -1
  32. package/dist/agent/app-manifest.js.map +1 -1
  33. package/dist/agent/channels/slack-agent-session.d.ts +81 -0
  34. package/dist/agent/channels/slack-agent-session.d.ts.map +1 -0
  35. package/dist/agent/channels/slack-agent-session.js +198 -0
  36. package/dist/agent/channels/slack-agent-session.js.map +1 -0
  37. package/dist/agent/channels/slack-attachments.d.ts +11 -0
  38. package/dist/agent/channels/slack-attachments.d.ts.map +1 -1
  39. package/dist/agent/channels/slack-attachments.js +21 -14
  40. package/dist/agent/channels/slack-attachments.js.map +1 -1
  41. package/dist/agent/channels/slack-commands.d.ts +113 -10
  42. package/dist/agent/channels/slack-commands.d.ts.map +1 -1
  43. package/dist/agent/channels/slack-commands.js +146 -13
  44. package/dist/agent/channels/slack-commands.js.map +1 -1
  45. package/dist/agent/channels/slack-context.d.ts +1 -2
  46. package/dist/agent/channels/slack-context.d.ts.map +1 -1
  47. package/dist/agent/channels/slack-context.js +0 -4
  48. package/dist/agent/channels/slack-context.js.map +1 -1
  49. package/dist/agent/channels/slack-events.d.ts +46 -97
  50. package/dist/agent/channels/slack-events.d.ts.map +1 -1
  51. package/dist/agent/channels/slack-events.js +86 -21
  52. package/dist/agent/channels/slack-events.js.map +1 -1
  53. package/dist/agent/channels/slack-follow-ups.d.ts +44 -0
  54. package/dist/agent/channels/slack-follow-ups.d.ts.map +1 -0
  55. package/dist/agent/channels/slack-follow-ups.js +129 -0
  56. package/dist/agent/channels/slack-follow-ups.js.map +1 -0
  57. package/dist/agent/channels/slack-front.d.ts +28 -45
  58. package/dist/agent/channels/slack-front.d.ts.map +1 -1
  59. package/dist/agent/channels/slack-front.js +207 -22
  60. package/dist/agent/channels/slack-front.js.map +1 -1
  61. package/dist/agent/channels/slack-home.d.ts.map +1 -1
  62. package/dist/agent/channels/slack-home.js +10 -3
  63. package/dist/agent/channels/slack-home.js.map +1 -1
  64. package/dist/agent/channels/slack-identity.d.ts +7 -21
  65. package/dist/agent/channels/slack-identity.d.ts.map +1 -1
  66. package/dist/agent/channels/slack-identity.js +3 -1
  67. package/dist/agent/channels/slack-identity.js.map +1 -1
  68. package/dist/agent/channels/slack-memory-canvas.d.ts +21 -5
  69. package/dist/agent/channels/slack-memory-canvas.d.ts.map +1 -1
  70. package/dist/agent/channels/slack-memory-canvas.js +162 -52
  71. package/dist/agent/channels/slack-memory-canvas.js.map +1 -1
  72. package/dist/agent/channels/slack-origin-codec.d.ts.map +1 -1
  73. package/dist/agent/channels/slack-origin-codec.js +10 -0
  74. package/dist/agent/channels/slack-origin-codec.js.map +1 -1
  75. package/dist/agent/channels/slack-post-arm.d.ts +21 -1
  76. package/dist/agent/channels/slack-post-arm.d.ts.map +1 -1
  77. package/dist/agent/channels/slack-post-arm.js +57 -10
  78. package/dist/agent/channels/slack-post-arm.js.map +1 -1
  79. package/dist/agent/channels/slack-read.d.ts +11 -1
  80. package/dist/agent/channels/slack-read.d.ts.map +1 -1
  81. package/dist/agent/channels/slack-read.js +27 -3
  82. package/dist/agent/channels/slack-read.js.map +1 -1
  83. package/dist/agent/channels/slack-reply-blocks.d.ts +29 -0
  84. package/dist/agent/channels/slack-reply-blocks.d.ts.map +1 -0
  85. package/dist/agent/channels/slack-reply-blocks.js +172 -0
  86. package/dist/agent/channels/slack-reply-blocks.js.map +1 -0
  87. package/dist/agent/channels/slack-reply-feedback.d.ts +23 -0
  88. package/dist/agent/channels/slack-reply-feedback.d.ts.map +1 -0
  89. package/dist/agent/channels/slack-reply-feedback.js +63 -0
  90. package/dist/agent/channels/slack-reply-feedback.js.map +1 -0
  91. package/dist/agent/channels/slack-shortcut.d.ts +10 -0
  92. package/dist/agent/channels/slack-shortcut.d.ts.map +1 -1
  93. package/dist/agent/channels/slack-shortcut.js +1 -1
  94. package/dist/agent/channels/slack-shortcut.js.map +1 -1
  95. package/dist/agent/channels/slack-voice-clips.d.ts +16 -0
  96. package/dist/agent/channels/slack-voice-clips.d.ts.map +1 -0
  97. package/dist/agent/channels/slack-voice-clips.js +80 -0
  98. package/dist/agent/channels/slack-voice-clips.js.map +1 -0
  99. package/dist/agent/channels/slack-wake-backscroll.d.ts +0 -2
  100. package/dist/agent/channels/slack-wake-backscroll.d.ts.map +1 -1
  101. package/dist/agent/channels/slack-wake-backscroll.js +0 -4
  102. package/dist/agent/channels/slack-wake-backscroll.js.map +1 -1
  103. package/dist/agent/channels/slack.d.ts +22 -1
  104. package/dist/agent/channels/slack.d.ts.map +1 -1
  105. package/dist/agent/channels/slack.js +63 -13
  106. package/dist/agent/channels/slack.js.map +1 -1
  107. package/dist/agent/docs-generated.d.ts.map +1 -1
  108. package/dist/agent/docs-generated.js +1282 -2
  109. package/dist/agent/docs-generated.js.map +1 -1
  110. package/dist/agent/ops.d.ts +1 -1
  111. package/dist/agent/ops.d.ts.map +1 -1
  112. package/dist/agent/ops.js +15 -21
  113. package/dist/agent/ops.js.map +1 -1
  114. package/dist/agent/register.d.ts.map +1 -1
  115. package/dist/agent/register.js +12 -1
  116. package/dist/agent/register.js.map +1 -1
  117. package/dist/agent/setup/credentials.d.ts +12 -0
  118. package/dist/agent/setup/credentials.d.ts.map +1 -1
  119. package/dist/agent/setup/credentials.js +17 -0
  120. package/dist/agent/setup/credentials.js.map +1 -1
  121. package/dist/agent/setup/page.d.ts.map +1 -1
  122. package/dist/agent/setup/page.js +6 -3
  123. package/dist/agent/setup/page.js.map +1 -1
  124. package/dist/agent/surface-parity.d.ts.map +1 -1
  125. package/dist/agent/surface-parity.js +2 -0
  126. package/dist/agent/surface-parity.js.map +1 -1
  127. package/package.json +31 -3
@@ -24,14 +24,23 @@
24
24
  // reports, until the next memory write or trust event re-runs it (docs/capabilities/memory.md lists
25
25
  // every limit).
26
26
  //
27
- // 🔴 A CANVAS THE APP DID NOT CREATE IS NEVER TOUCHED. A channel has at most one channel canvas, and
28
- // people write their own. When the channel already has one, the app rewrites it only when
29
- // `files.info` names the app's own bot user as its creator. A person's canvas stays theirs, and the
30
- // refusal is logged and audited so an operator can see why a channel shows no memory.
27
+ // 🔴 ONLY THE MEMORY CANVAS IS EVER TOUCHED (OPE-2587). A channel can show more than one canvas (the
28
+ // channel canvas and its canvas tabs). People write their own, and the agent creates canvases for
29
+ // people through `write_canvas`, with the app's own bot user as the creator. So the creator does not
30
+ // mark the memory canvas. The app records the id `conversations.canvases.create` answers in the
31
+ // `memory_canvas` row and rewrites only that canvas (core `memoryCanvasAmong` names the rule, and how
32
+ // a canvas made before the row existed is adopted). Every other canvas stays as its owner left it,
33
+ // unless a person types the memory canvas footer line into it by hand (core `memoryCanvasAmong` rule
34
+ // 3). A channel whose canvas slot another canvas holds gets no memory canvas, and the refusal is logged
35
+ // and audited so an operator can see why a channel shows no memory.
31
36
  //
32
37
  // 🔴 WHAT A CANVAS DOES NOT DO. It is a READ view. Members correct or forget a line by asking the
33
38
  // agent in the channel, which goes through the tools and the approval card like any other change.
34
39
  // An edit made directly in the canvas is overwritten on the next memory write — the canvas says so.
40
+ // The agent's own `write_canvas` refuses this canvas for the same reason (core
41
+ // channels/slack-canvas-list.ts `refuseMemoryCanvasWrite`, OPE-2585): both sides find the memory
42
+ // canvas through the one core rule, `memoryCanvasAmong`. It reads the `properties.canvas` field AND
43
+ // every canvas tab (`properties.tabs[]`, OPE-2587), because Slack can hold a channel canvas only as a tab.
35
44
 
36
45
  import {
37
46
  agentDisplayName,
@@ -39,11 +48,21 @@ import {
39
48
  domainForChannel,
40
49
  domainsNamingSilo,
41
50
  getConfig,
51
+ getOperationalStore,
52
+ MEMORY_CANVAS_MARKER,
53
+ memoryCanvasAmong,
54
+ memoryCanvasSurfaceKey,
42
55
  readDomainMemory,
56
+ storedMemoryCanvas,
43
57
  memoryLineForReader,
44
58
  renderMemoryMarkdown,
59
+ scrubAuditText,
60
+ type AuditDetail,
45
61
  type Domain,
62
+ type MemoryCanvasMatch,
46
63
  type MemoryWriteEvent,
64
+ type OperationalStore,
65
+ type SlackCanvasRead,
47
66
  } from "@open-everywhere/core/agent/adapter-api.js";
48
67
  import { confirmChannelInternalFresh, slackAuthorIsMember, slackForm } from "./slack-events.js";
49
68
  import { encodeSlackForm } from "./slack-web-api.js";
@@ -86,6 +105,68 @@ export interface MemoryCanvasDeps {
86
105
  * `users.info` probe (slack-events.ts `slackAuthorIsMember`). */
87
106
  readonly authorIsMember: (userId: string, teamId: string | undefined) => Promise<boolean | undefined>;
88
107
  readonly now?: () => Date;
108
+ /** The `memory_canvas` row (OPE-2587). The live dep is the operational store. */
109
+ readonly store?: Pick<OperationalStore, "getMemoryCanvas" | "setMemoryCanvas">;
110
+ }
111
+
112
+ /** The Slack refusal behind a `failed` or `clear-failed` outcome, for the audit row (OPE-2600). `error`
113
+ * is Slack's error code (`missing_scope`, `channel_not_found`, …), or `no_response` when the call gave
114
+ * no answer at all. `needed` is the scope Slack names on a `missing_scope`. Operator data only: no
115
+ * person-facing text carries it. */
116
+ interface CanvasSlackError {
117
+ readonly method: string;
118
+ readonly error: string;
119
+ readonly needed?: string;
120
+ }
121
+
122
+ /** The longest Slack-supplied string the audit row keeps. Slack's codes and scope lists are far shorter. */
123
+ const SLACK_ERROR_TEXT_MAX = 120;
124
+
125
+ /** Slack's own text, kept only when it is a short code or scope list: never a free-text blob. */
126
+ function slackCode(v: unknown): string | undefined {
127
+ return typeof v === "string" && v.length > 0 && v.length <= SLACK_ERROR_TEXT_MAX && /^[A-Za-z0-9_.:,-]+$/.test(v) ? v : undefined;
128
+ }
129
+
130
+ /**
131
+ * Wrap the deps of ONE canvas decision so the decision's last Slack refusal is kept (OPE-2600). Every
132
+ * lookup on the canvas path is fail-soft, and core's {@link memoryCanvasAmong} reads only `ok`, so
133
+ * without this a scope gap and a product defect both reach the audit row as a bare `failed`. The
134
+ * LAST refusal of the decision is kept. On most paths that is the call that decided the outcome. An
135
+ * earlier refusal can also have only shaped the path: a refused `auth.test` leaves the bot unknown, and
136
+ * a refused `conversations.members` sends the decision to the take-down. A throw is never a Slack
137
+ * refusal, so the callers drop the refusal on their throw path.
138
+ */
139
+ function recordSlackRefusals(deps: MemoryCanvasDeps): { deps: MemoryCanvasDeps; refusal: () => CanvasSlackError | undefined } {
140
+ let last: CanvasSlackError | undefined;
141
+ return {
142
+ deps: {
143
+ ...deps,
144
+ call: async (method, body) => {
145
+ const res = await deps.call(method, body);
146
+ if (res === undefined) last = { method, error: "no_response" };
147
+ else if (res.ok !== true) {
148
+ const needed = slackCode(res.needed);
149
+ last = { method, error: slackCode(res.error) ?? "unknown", ...(needed ? { needed } : {}) };
150
+ }
151
+ return res;
152
+ },
153
+ },
154
+ refusal: () => last,
155
+ };
156
+ }
157
+
158
+ /** The Slack refusal behind an outcome, when it has one: present only on `failed` and `clear-failed`,
159
+ * and only when a Slack call was refused. A failure with no Slack refusal (a store read, a throw)
160
+ * answers undefined. */
161
+ function refusalFor(outcome: CanvasOutcome, refusal: CanvasSlackError | undefined): CanvasSlackError | undefined {
162
+ return outcome === "failed" || outcome === "clear-failed" ? refusal : undefined;
163
+ }
164
+
165
+ /** The audit detail fields for a refusal: `slackError` (Slack's code, through the audit scrub, as every
166
+ * error-named key must be), `slackMethod`, and `slackNeeded` when Slack names a scope. */
167
+ function slackErrorDetail(e: CanvasSlackError | undefined): AuditDetail {
168
+ if (!e) return {};
169
+ return { slackError: scrubAuditText(e.error), slackMethod: e.method, ...(e.needed ? { slackNeeded: scrubAuditText(e.needed) } : {}) };
89
170
  }
90
171
 
91
172
  /** The live deps: every call through `slackForm` (the one Web API seam, fail-soft), the room verdict
@@ -113,7 +194,7 @@ function memoryCanvasMarkdown(domain: Domain, groups: Parameters<typeof renderMe
113
194
  const body = renderMemoryMarkdown(`What ${name} knows — ${domain.title ?? domain.name}`, groups, nowIso);
114
195
  const footer =
115
196
  `\n---\n${name} keeps this canvas up to date. To correct or forget a line, ask ${name} in this channel. ` +
116
- `Edits made directly to this canvas get replaced.\n`;
197
+ `${MEMORY_CANVAS_MARKER}\n`;
117
198
  // OPE-2486 — the memory provenance carriers read as plain words on a canvas a whole channel reads.
118
199
  const text = defuseCanvasText(memoryLineForReader(body));
119
200
  const cut = text.length > CANVAS_MARKDOWN_MAX ? text.slice(0, CANVAS_MARKDOWN_MAX) + "\n\n_This canvas is full. Ask in this channel for the rest._\n" : text;
@@ -184,7 +265,9 @@ function withdrawnCanvasMarkdown(): string {
184
265
  return (
185
266
  `# ${name}'s memory canvas was withdrawn\n\n` +
186
267
  `This channel includes guests or people from outside your organisation, so ${name} no longer shows its memory here. ` +
187
- `The memory itself is unchanged. Members can still ask ${name} about it in an internal channel.\n`
268
+ `The memory itself is unchanged. Members can still ask ${name} about it in an internal channel. ` +
269
+ // The marker keeps the withdrawn canvas the memory canvas, so a later refresh rewrites it in place.
270
+ `${MEMORY_CANVAS_MARKER}\n`
188
271
  );
189
272
  }
190
273
 
@@ -192,78 +275,121 @@ function withdrawnCanvasMarkdown(): string {
192
275
  * longer belongs to a domain that shows one (`memoryCanvas: false`, no `memory`, or no domain). */
193
276
  type WithdrawReason = "external" | "guest" | "disabled";
194
277
 
278
+ /** The channel one canvas decision is about, and the org and team its `memory_canvas` row is keyed by. */
279
+ export interface CanvasRoom {
280
+ readonly org: string;
281
+ readonly teamId: string;
282
+ readonly channelId: string;
283
+ }
284
+
285
+ /** Slack's answer when it answered `ok`, else undefined: the read shape core's rule takes. */
286
+ function readOk(deps: MemoryCanvasDeps): SlackCanvasRead {
287
+ return async (method, params) => {
288
+ const res = await deps.call(method, params);
289
+ return res?.ok === true ? res : undefined;
290
+ };
291
+ }
292
+
293
+ /** The memory canvas among the channel's canvases, by core's one rule (OPE-2587). */
294
+ async function findMemoryCanvas(room: CanvasRoom, info: Record<string, unknown>, botUserId: string | undefined, deps: MemoryCanvasDeps): Promise<MemoryCanvasMatch> {
295
+ const stored = await storedMemoryCanvas(deps.store ?? getOperationalStore(), room.org, memoryCanvasSurfaceKey(room.teamId, room.channelId));
296
+ return memoryCanvasAmong(readOk(deps), info, stored, botUserId);
297
+ }
298
+
299
+ /** Record the room's memory canvas id. A failed write is not an outcome: the row then names an older id
300
+ * or none, the canvas carries {@link MEMORY_CANVAS_MARKER}, and core's rule scans for the marker
301
+ * whenever the row's id is not on screen. So the next decision finds this canvas again. */
302
+ async function recordMemoryCanvas(room: CanvasRoom, canvasId: string, deps: MemoryCanvasDeps): Promise<void> {
303
+ const at = (deps.now?.() ?? new Date()).toISOString();
304
+ await (deps.store ?? getOperationalStore()).setMemoryCanvas(room.org, memoryCanvasSurfaceKey(room.teamId, room.channelId), canvasId, at).catch(() => undefined);
305
+ }
306
+
195
307
  /**
196
- * Take the app's OWN canvas down in one channel, if it has one. FAIL-CLOSED ON EVERY LOOKUP: a failed
197
- * `conversations.info`, a failed `files.info`, or an unknown bot identity answers `clear-failed`, never
198
- * a skip — the caller cannot rule out that the app's canvas is still on screen, and the audit row must
199
- * say so. `skipped-*` is kept for the two answers that PROVE nothing of ours is there: no channel
200
- * canvas at all, or a canvas whose creator is somebody else.
308
+ * Take the memory canvas down in one channel, if it has one. FAIL-CLOSED ON EVERY LOOKUP: a failed
309
+ * `conversations.info`, store read, `files.info` or section lookup, or an unknown bot identity, answers
310
+ * `clear-failed`, never a skip — the caller cannot rule out that the memory canvas is still on screen,
311
+ * and the audit row must say so. `skipped-*` is kept for the answer that PROVES no memory canvas is on
312
+ * screen ({@link memoryCanvasAmong} answers `none`). Only the memory canvas is cleared: a person's
313
+ * canvas, and a canvas the agent made for a person through `write_canvas`, are not touched (OPE-2587),
314
+ * unless a person typed the memory canvas footer line into one by hand ({@link memoryCanvasAmong} rule 3).
201
315
  */
202
- async function withdrawIfOurs(channelId: string, reason: WithdrawReason, botUserId: string | undefined, deps: MemoryCanvasDeps): Promise<CanvasOutcome> {
203
- const info = await deps.call("conversations.info", { channel: channelId });
316
+ async function withdrawIfOurs(room: CanvasRoom, reason: WithdrawReason, botUserId: string | undefined, deps: MemoryCanvasDeps): Promise<CanvasOutcome> {
317
+ const info = await deps.call("conversations.info", { channel: room.channelId });
204
318
  if (info?.ok !== true) return "clear-failed";
205
- const existing = canvasIdOf(info);
206
- if (!existing) return `skipped-${reason}`;
207
- const file = await deps.call("files.info", { file: existing });
208
- if (file?.ok !== true) return "clear-failed";
209
- const creator = (file.file as { user?: unknown } | undefined)?.user;
210
- if (botUserId === undefined) return "clear-failed";
211
- if (creator !== botUserId) return `skipped-${reason}`;
319
+ const match = await findMemoryCanvas(room, info, botUserId, deps);
320
+ if (match.kind === "unsure") return "clear-failed";
321
+ if (match.kind === "none") return `skipped-${reason}`;
212
322
  const cleared = await deps.call("canvases.edit", {
213
- canvas_id: existing,
323
+ canvas_id: match.canvasId,
214
324
  changes: [{ operation: "replace", document_content: { type: "markdown", markdown: withdrawnCanvasMarkdown() } }],
215
325
  });
216
- return cleared?.ok === true ? `cleared-${reason}` : "clear-failed";
326
+ if (cleared?.ok !== true) return "clear-failed";
327
+ if (match.adopt) await recordMemoryCanvas(room, match.canvasId, deps);
328
+ return `cleared-${reason}`;
217
329
  }
218
330
 
219
- function canvasIdOf(info: Record<string, unknown>): string | undefined {
220
- const props = (info.channel as { properties?: { canvas?: { file_id?: unknown } } } | undefined)?.properties;
221
- return typeof props?.canvas?.file_id === "string" ? props.canvas.file_id : undefined;
222
- }
331
+ /** Slack's refusals of `conversations.canvases.create` that mean the channel's canvas slot holds a
332
+ * canvas that is not the memory canvas: a person's channel canvas, or the one tab a free plan allows,
333
+ * which a person's canvas or a canvas the agent made through `write_canvas` can hold. */
334
+ const CANVAS_SLOT_TAKEN = new Set(["channel_canvas_already_exists", "free_team_canvas_tab_already_exists"]);
223
335
 
224
336
  /**
225
337
  * Write one channel's canvas — or, with `markdown: null`, only RECHECK it. Never throws.
226
338
  *
227
- * 🔴 THE GATE RUNS BEFORE EVERY DECISION, AND A FAILED GATE TAKES THE APP'S OWN CANVAS DOWN (OPE-2451,
339
+ * 🔴 THE GATE RUNS BEFORE EVERY DECISION, AND A FAILED GATE TAKES THE MEMORY CANVAS DOWN (OPE-2451,
228
340
  * the brain's security review). A canvas the app wrote while the channel passed outlives the room's
229
341
  * trust otherwise: once the channel is shared, or a guest joins, a refresh that merely SKIPS leaves
230
342
  * every line of the domain's memory on screen for the new audience. So on either refusal the app
231
- * replaces its own canvas with {@link withdrawnCanvasMarkdown} ({@link withdrawIfOurs}). A canvas a
232
- * PERSON made is never touched, in either direction.
343
+ * replaces the memory canvas with {@link withdrawnCanvasMarkdown} ({@link withdrawIfOurs}).
344
+ *
345
+ * 🔴 ONLY THE MEMORY CANVAS IS WRITTEN (OPE-2587). Slack shows a channel's canvases as tabs, and the
346
+ * canvas a person approves through `write_canvas` has the app's bot user as its creator too. So the app
347
+ * rewrites only the canvas core's {@link memoryCanvasAmong} finds: the id its `memory_canvas` row
348
+ * holds, or, for a canvas made before the row existed, the rule that function names. A channel with no
349
+ * memory canvas gets one, and the row records its id. A person's canvas and a `write_canvas` canvas are
350
+ * not touched, in either direction, unless a person typed the memory canvas footer line into one by
351
+ * hand ({@link memoryCanvasAmong} rule 3). A lookup that leaves the answer open writes nothing (`failed`),
352
+ * because a new canvas could then duplicate the memory canvas.
233
353
  *
234
354
  * `null` markdown is the recheck the trust EVENTS run: it takes the canvas down on a failed gate and
235
355
  * writes nothing on a passing one (`unchanged`). `joinedUserId` is the person a join event named: they
236
356
  * are probed DIRECTLY, because Slack's member list may not carry them yet when the event arrives.
237
357
  */
238
358
  export async function publishChannelCanvas(
239
- channelId: string,
359
+ room: CanvasRoom,
240
360
  markdown: string | null,
241
361
  botUserId: string | undefined,
242
362
  deps: MemoryCanvasDeps,
243
- teamId?: string,
244
363
  joinedUserId?: string,
245
364
  ): Promise<CanvasOutcome> {
365
+ const { channelId, teamId } = room;
246
366
  const blocked: "external" | "guest" | undefined = !(await deps.channelInternal(channelId))
247
367
  ? "external"
248
368
  : (joinedUserId !== undefined && joinedUserId !== botUserId && (await deps.authorIsMember(joinedUserId, teamId)) !== true) ||
249
369
  !(await everyMemberIsFull(channelId, teamId, botUserId, deps))
250
370
  ? "guest"
251
371
  : undefined;
252
- if (blocked) return withdrawIfOurs(channelId, blocked, botUserId, deps);
372
+ if (blocked) return withdrawIfOurs(room, blocked, botUserId, deps);
253
373
  if (markdown === null) return "unchanged";
254
374
  const info = await deps.call("conversations.info", { channel: channelId });
255
375
  if (info?.ok !== true) return "failed";
256
- const existing = canvasIdOf(info);
257
- if (!existing) {
376
+ const match = await findMemoryCanvas(room, info, botUserId, deps);
377
+ if (match.kind === "unsure") return "failed";
378
+ if (match.kind === "none") {
258
379
  const created = await deps.call("conversations.canvases.create", { channel_id: channelId, document_content: { type: "markdown", markdown } });
259
- return created?.ok === true ? "created" : "failed";
380
+ if (created?.ok !== true) return typeof created?.error === "string" && CANVAS_SLOT_TAKEN.has(created.error) ? "skipped-foreign" : "failed";
381
+ if (typeof created.canvas_id === "string" && created.canvas_id.length > 0) await recordMemoryCanvas(room, created.canvas_id, deps);
382
+ return "created";
260
383
  }
261
- // Ownership before any write: only a canvas the app's own bot user created is the app's to replace.
262
- const file = await deps.call("files.info", { file: existing });
263
- const creator = file?.ok === true ? (file.file as { user?: unknown } | undefined)?.user : undefined;
264
- if (botUserId === undefined || creator !== botUserId) return "skipped-foreign";
265
- const edited = await deps.call("canvases.edit", { canvas_id: existing, changes: [{ operation: "replace", document_content: { type: "markdown", markdown } }] });
266
- return edited?.ok === true ? "updated" : "failed";
384
+ const edited = await deps.call("canvases.edit", { canvas_id: match.canvasId, changes: [{ operation: "replace", document_content: { type: "markdown", markdown } }] });
385
+ if (edited?.ok !== true) return "failed";
386
+ if (match.adopt) await recordMemoryCanvas(room, match.canvasId, deps);
387
+ return "updated";
388
+ }
389
+
390
+ /** The deploy-log tail for a Slack refusal: the method and code, and the scope Slack names. */
391
+ function slackErrorLog(e: CanvasSlackError | undefined): string {
392
+ return e ? ` (${e.method}: ${e.error}${e.needed ? `, needs ${e.needed}` : ""})` : "";
267
393
  }
268
394
 
269
395
  /** What woke a canvas decision: a memory write, or one of the two trust events. */
@@ -290,16 +416,24 @@ export async function recheckChannelCanvas(
290
416
  deps: MemoryCanvasDeps,
291
417
  ): Promise<CanvasOutcome> {
292
418
  const surfaceKey = `slack/${input.teamId}/${input.channelId}`;
419
+ const room: CanvasRoom = { org: input.org, teamId: input.teamId, channelId: input.channelId };
293
420
  const domain = domainForChannel(surfaceKey, getConfig().loadDomains());
294
421
  const shows = domain !== undefined && domain.memory !== undefined && domain.memoryCanvas !== false;
295
- const auth = await deps.call("auth.test", {});
422
+ // The recorder starts before `auth.test`: an unknown bot identity is a refusal the row should name.
423
+ const recorded = recordSlackRefusals(deps);
424
+ const auth = await recorded.deps.call("auth.test", {});
296
425
  const botUserId = auth?.ok === true && typeof auth.user_id === "string" ? auth.user_id : undefined;
426
+ let threw = false;
297
427
  const outcome = await (shows
298
- ? publishChannelCanvas(input.channelId, null, botUserId, deps, input.teamId, input.joinedUserId)
299
- : withdrawIfOurs(input.channelId, "disabled", botUserId, deps)
300
- ).catch((): CanvasOutcome => "clear-failed");
428
+ ? publishChannelCanvas(room, null, botUserId, recorded.deps, input.joinedUserId)
429
+ : withdrawIfOurs(room, "disabled", botUserId, recorded.deps)
430
+ ).catch((): CanvasOutcome => {
431
+ threw = true;
432
+ return "clear-failed";
433
+ });
434
+ const refusal = threw ? undefined : refusalFor(outcome, recorded.refusal());
301
435
  if (outcome !== "unchanged" && outcome !== "skipped-disabled") {
302
- console.log(`[open-everywhere:slack-canvas] ${input.trigger} recheck, channel ${input.channelId}: ${outcome}`);
436
+ console.log(`[open-everywhere:slack-canvas] ${input.trigger} recheck, channel ${input.channelId}: ${outcome}${slackErrorLog(refusal)}`);
303
437
  }
304
438
  await appendAudit({
305
439
  org: input.org,
@@ -307,7 +441,7 @@ export async function recheckChannelCanvas(
307
441
  workspace: input.teamId,
308
442
  surface: input.channelId,
309
443
  action: MEMORY_CANVAS_ACTION,
310
- detail: { domain: domain?.name ?? null, trigger: input.trigger, outcome },
444
+ detail: { domain: domain?.name ?? null, trigger: input.trigger, outcome, ...slackErrorDetail(refusal) },
311
445
  }).catch(() => undefined);
312
446
  return outcome;
313
447
  }
@@ -337,11 +471,18 @@ export async function refreshMemoryCanvases(event: MemoryWriteEvent, deps: Memor
337
471
  const markdown = shows ? memoryCanvasMarkdown(domain, await readDomainMemory(event.org, domain), nowIso) : "";
338
472
  for (const room of rooms) {
339
473
  // A throw on the clear-only path cannot rule out that the old canvas is still up, so it says so.
340
- const outcome = await (shows ? publishChannelCanvas(room.channelId, markdown, botUserId, deps, teamId) : withdrawIfOurs(room.channelId, "disabled", botUserId, deps)).catch(
341
- (): CanvasOutcome => (shows ? "failed" : "clear-failed"),
474
+ const canvasRoom: CanvasRoom = { org: event.org, teamId, channelId: room.channelId };
475
+ const recorded = recordSlackRefusals(deps);
476
+ let threw = false;
477
+ const outcome = await (shows ? publishChannelCanvas(canvasRoom, markdown, botUserId, recorded.deps) : withdrawIfOurs(canvasRoom, "disabled", botUserId, recorded.deps)).catch(
478
+ (): CanvasOutcome => {
479
+ threw = true;
480
+ return shows ? "failed" : "clear-failed";
481
+ },
342
482
  );
483
+ const refusal = threw ? undefined : refusalFor(outcome, recorded.refusal());
343
484
  if (outcome !== "created" && outcome !== "updated") {
344
- console.log(`[open-everywhere:slack-canvas] domain "${domain.name}" channel ${room.channelId}: ${outcome}`);
485
+ console.log(`[open-everywhere:slack-canvas] domain "${domain.name}" channel ${room.channelId}: ${outcome}${slackErrorLog(refusal)}`);
345
486
  }
346
487
  const [platform, workspace, surface] = room.surfaceKey.split("/");
347
488
  await appendAudit({
@@ -350,7 +491,7 @@ export async function refreshMemoryCanvases(event: MemoryWriteEvent, deps: Memor
350
491
  workspace: workspace!,
351
492
  surface: surface!,
352
493
  action: MEMORY_CANVAS_ACTION,
353
- detail: { domain: domain.name, trigger: "memory-write", silo: event.silo, outcome, chars: markdown.length },
494
+ detail: { domain: domain.name, trigger: "memory-write", silo: event.silo, outcome, chars: markdown.length, ...slackErrorDetail(refusal) },
354
495
  }).catch(() => undefined);
355
496
  }
356
497
  }
@@ -63,6 +63,15 @@ export const slackWakeOriginCodec: WakeOriginCodec<SlackOrigin> = {
63
63
  const ts = typeof origin.threadTs === "string" && origin.threadTs.length > 0 ? origin.threadTs : typeof origin.replyAnchorTs === "string" && origin.replyAnchorTs.length > 0 ? origin.replyAnchorTs : undefined;
64
64
  return ts === undefined ? undefined : { channelId: origin.channelId, messageId: ts };
65
65
  },
66
+ // THE THREAD A REPLY OPENS (OPE-2532). A post that is already in a thread answers undefined, because its
67
+ // own `threadTs` is the key. A post placed under an anchor lands in the anchor's thread (the post arm
68
+ // reads `threadTs ?? replyAnchorTs`), and Slack threads do not nest, so a reply carries the anchor. A
69
+ // top-level post opens a thread under its own ts.
70
+ replyThreadUnder: (origin, messageId) => {
71
+ if (typeof origin.threadTs === "string" && origin.threadTs.length > 0) return undefined;
72
+ const anchor = typeof origin.replyAnchorTs === "string" && origin.replyAnchorTs.length > 0 ? origin.replyAnchorTs : messageId;
73
+ return { platform: "slack", channelId: origin.channelId, threadTs: anchor };
74
+ },
66
75
  // A `dmOrigin` arm lived here (OPE-306) to mint a DM delegation anchor's reply origin from the
67
76
  // binding. OPE-368 deleted the anchor and the interface member with it — a Slack 1:1 or group DM
68
77
  // now diverts into its own first-class window (OPE-351 stages 2 + 4), so its origin is minted and
@@ -6,9 +6,11 @@
6
6
 
7
7
  import { slackForm, botDisplayName } from "./slack-events.js";
8
8
  import { markdownToSlackMrkdwn } from "./slack-mrkdwn.js";
9
+ import { isSlackBlocksRefusal, slackReplyBlocks } from "./slack-reply-blocks.js";
9
10
  import { markNotApplied } from "@open-everywhere/core/agent/adapter-api.js";
10
11
  import type {
11
12
  PostArm,
13
+ PostIntent,
12
14
  TargetResolveBag,
13
15
  ReplyTarget,
14
16
  ReplierLabel,
@@ -131,11 +133,39 @@ function classifiedPostError(res: SlackPostResult | undefined, message: string):
131
133
  return res?.ok === false ? markNotApplied(err) : err;
132
134
  }
133
135
 
136
+ /**
137
+ * Post an ANSWER (OPE-2520): `base` carries the channel, the thread and the mrkdwn `text`; when the
138
+ * intent asks for source links or the native thumbs, the first attempt adds the answer's `blocks`
139
+ * (slack-reply-blocks.ts). A post Slack refuses FOR ITS BLOCKS is retried once without them, so a
140
+ * workspace that rejects a block still gets the answer. Only that refusal retries: Slack answered
141
+ * `ok:false`, so nothing posted and the plain retry cannot double the message.
142
+ */
143
+ export async function postSlackAnswer(
144
+ base: URLSearchParams,
145
+ intent: PostIntent | undefined,
146
+ replier: ReplierLabel | undefined,
147
+ errPrefix: string,
148
+ post: SlackPostFn = livePost,
149
+ resolveDisplayName: () => Promise<string | undefined> = botDisplayName,
150
+ ): Promise<string> {
151
+ const blocks = slackReplyBlocks(base.get("text") ?? "", intent);
152
+ if (blocks === undefined) return postSlackMessage(base, replier, errPrefix, post, resolveDisplayName);
153
+ const withBlocks = new URLSearchParams(base);
154
+ withBlocks.set("blocks", JSON.stringify(blocks));
155
+ try {
156
+ return await postSlackMessage(withBlocks, replier, errPrefix, post, resolveDisplayName);
157
+ } catch (err) {
158
+ if (!isSlackBlocksRefusal(err)) throw err;
159
+ console.warn("[open-everywhere:slack-post-arm] Slack refused the answer's blocks; posting the text alone");
160
+ return postSlackMessage(base, replier, errPrefix, post, resolveDisplayName);
161
+ }
162
+ }
163
+
134
164
  /** MID-TURN: post via chat.postMessage (returns `ts`), thread-anchored when the trigger was threaded. */
135
- async function dispatchSlackReply(target: SlackTarget, text: string, replier?: ReplierLabel): Promise<string> {
165
+ async function dispatchSlackReply(target: SlackTarget, text: string, replier?: ReplierLabel, intent?: PostIntent): Promise<string> {
136
166
  const body = new URLSearchParams({ channel: target.channelId, text: markdownToSlackMrkdwn(text) });
137
167
  if (target.threadTs) body.set("thread_ts", target.threadTs);
138
- return postSlackMessage(body, replier, "reply couldn't post to Slack");
168
+ return postSlackAnswer(body, intent, replier, "reply couldn't post to Slack");
139
169
  }
140
170
 
141
171
  /**
@@ -155,11 +185,11 @@ export function slackThreadTarget(origin: Pick<SlackOrigin, "threadTs" | "replyA
155
185
  }
156
186
 
157
187
  /** UNIFIED: post into the wake origin's channel/thread (planSupervisorReply's exact semantics). */
158
- async function liveSlackPost(origin: SlackOrigin, text: string, replier?: ReplierLabel): Promise<string> {
188
+ async function liveSlackPost(origin: SlackOrigin, text: string, replier?: ReplierLabel, intent?: PostIntent): Promise<string> {
159
189
  const body = new URLSearchParams({ channel: origin.channelId, text: markdownToSlackMrkdwn(text) });
160
190
  const threadTarget = slackThreadTarget(origin);
161
191
  if (threadTarget) body.set("thread_ts", threadTarget);
162
- return postSlackMessage(body, replier, "unified reply couldn't post to Slack");
192
+ return postSlackAnswer(body, intent, replier, "unified reply couldn't post to Slack");
163
193
  }
164
194
 
165
195
  /**
@@ -238,9 +268,29 @@ async function deliverSlackDirectMessage(input: DirectMessageRequest): Promise<D
238
268
  * posted) is the backstop under that, and its refusal arrives as a token-free throw the model reads.
239
269
  * `chat.update` needs the same `chat:write` scope the post path already has.
240
270
  */
241
- async function editSlackMessage(locus: EditLocus, messageId: string, text: string): Promise<void> {
242
- const body = new URLSearchParams({ channel: locus.channelId, ts: messageId, text: markdownToSlackMrkdwn(text) });
243
- const res = await slackForm("chat.update", body.toString());
271
+ export async function editSlackMessage(
272
+ locus: EditLocus,
273
+ messageId: string,
274
+ text: string,
275
+ intent?: PostIntent,
276
+ /** The `chat.update` write — the live `slackForm` client; a test supplies a scripted one. */
277
+ form: (method: string, body: string) => Promise<Record<string, unknown> | undefined> = slackForm,
278
+ ): Promise<void> {
279
+ const rendered = markdownToSlackMrkdwn(text);
280
+ const body = new URLSearchParams({ channel: locus.channelId, ts: messageId, text: rendered });
281
+ // OPE-2520: an edit sends the answer's blocks too. A message posted with blocks shows its blocks,
282
+ // not its `text`, so an edit that sent `text` alone would leave the old answer on screen.
283
+ const blocks = slackReplyBlocks(rendered, intent);
284
+ if (blocks !== undefined) body.set("blocks", JSON.stringify(blocks));
285
+ let res = await form("chat.update", body.toString());
286
+ if (res?.ok !== true && blocks !== undefined && isSlackBlocksRefusal(String(res?.error))) {
287
+ // Slack refused the BLOCKS, so the message did not change. Retry once with the text alone and an
288
+ // EMPTY block list: `chat.update` keeps a message's existing blocks unless new ones are sent
289
+ // (surface-message-ops.ts), so a retry that only dropped the field would leave the old answer on
290
+ // screen while Slack answered `ok`.
291
+ body.set("blocks", "[]");
292
+ res = await form("chat.update", body.toString());
293
+ }
244
294
  if (res?.ok !== true) {
245
295
  // DEFINITIVE (OPE-802): `chat.update` answers HTTP 200 with `ok:false`, so Slack read the request
246
296
  // and decided against it — the message did not change. There is no status to carry that, so the
@@ -260,14 +310,21 @@ export const slackPostArm: PostArm = {
260
310
  return channelId ? { platform: "slack", channelId, threadTs: bag.threadTs } : null;
261
311
  },
262
312
  displayNameCapable: true,
263
- dispatchReply: (target, text, replier) => dispatchSlackReply(target as SlackTarget, text, replier),
264
- dispatchUnified: (origin, text, deps: UnifiedReplyDeps, replier) =>
265
- deps.postSlack ? deps.postSlack(origin as SlackOrigin, text) : liveSlackPost(origin as SlackOrigin, text, replier),
313
+ dispatchReply: (target, text, replier, intent) => dispatchSlackReply(target as SlackTarget, text, replier, intent),
314
+ // OPE-2558: a Slack target and a Slack origin name the same two facts, a channel and a thread.
315
+ originOfTarget: (target) => {
316
+ const t = target as SlackTarget;
317
+ return { platform: "slack", channelId: t.channelId, ...(t.threadTs !== undefined && t.threadTs.length > 0 ? { threadTs: t.threadTs } : {}) };
318
+ },
319
+ dispatchUnified: (origin, text, deps: UnifiedReplyDeps, replier, intent) =>
320
+ deps.postSlack ? deps.postSlack(origin as SlackOrigin, text) : liveSlackPost(origin as SlackOrigin, text, replier, intent),
321
+ // OPE-2520: a `D…` channel is a 1:1 DM between the app and one person. A group DM is `G…`/`C…`.
322
+ isDirectConversation: (locus) => locus.channelId.startsWith("D"),
266
323
  describeRedriveTarget: (origin) => {
267
324
  const o = origin as SlackOrigin;
268
325
  return `slack ${o.threadTs ? `thread in ${o.channelId}` : `channel ${o.channelId}`}`;
269
326
  },
270
327
  redriveContainerId: (origin) => (origin as SlackOrigin).channelId,
271
328
  deliverDirectMessage: deliverSlackDirectMessage,
272
- editMessage: editSlackMessage,
329
+ editMessage: (locus, messageId, text, intent) => editSlackMessage(locus, messageId, text, intent),
273
330
  };
@@ -25,6 +25,7 @@ import {
25
25
  markNotApplied,
26
26
  type BindableContext,
27
27
  type ReadScopeAuditDeps,
28
+ type ReadFile,
28
29
  type RenderedLine,
29
30
  type WakeReadAddress,
30
31
  domainRoster,
@@ -137,6 +138,27 @@ export interface RenderedMessage {
137
138
  /** A machine author (`bot_id` present). Excluded from the probe: a bot is below a member but is
138
139
  * not a person outside the org — the same exclusion access/taint-signal.ts makes for `relay`. */
139
140
  readonly bot?: boolean;
141
+ /** The message's files: id, name and media type only (core channel-read.ts `ReadFile`). */
142
+ readonly files?: readonly ReadFile[];
143
+ }
144
+
145
+ /**
146
+ * A Slack message's `files` array, reduced to what a reader needs: the file id (`F…`, the id
147
+ * `describe_image` resolves as an attachment), the name (else the title) and the `mimetype`.
148
+ * Every other field is dropped, `url_private` and the permalinks above all: a reader that held a
149
+ * URL could fetch outside the governed attachment seam. A deleted file (`mode: "tombstone"`) and an
150
+ * entry with no id are skipped. PURE.
151
+ */
152
+ export function slackMessageFiles(raw: unknown): ReadFile[] {
153
+ if (!Array.isArray(raw)) return [];
154
+ const out: ReadFile[] = [];
155
+ for (const f of raw as Array<Record<string, unknown>>) {
156
+ if (typeof f !== "object" || f === null) continue;
157
+ if (typeof f.id !== "string" || f.id.length === 0 || f.mode === "tombstone") continue;
158
+ const name = typeof f.name === "string" && f.name.length > 0 ? f.name : typeof f.title === "string" ? f.title : "";
159
+ out.push({ id: f.id, name, ...(typeof f.mimetype === "string" && f.mimetype.length > 0 ? { mimetype: f.mimetype } : {}) });
160
+ }
161
+ return out;
140
162
  }
141
163
 
142
164
  /**
@@ -155,9 +177,11 @@ export function renderMessages(raw: unknown, opts: { oldestFirst?: boolean } = {
155
177
  if (!Array.isArray(raw)) return [];
156
178
  const out: RenderedMessage[] = [];
157
179
  for (const m of raw as Array<Record<string, unknown>>) {
158
- if (typeof m.subtype === "string" && m.subtype.length > 0) continue; // skip joins/edits/system
180
+ const files = slackMessageFiles(m.files);
181
+ // Skip joins, edits and system lines. A `file_share` line is a person's upload, so it stays.
182
+ if (typeof m.subtype === "string" && m.subtype.length > 0 && !(m.subtype === "file_share" && files.length > 0)) continue;
159
183
  const text = typeof m.text === "string" ? m.text : "";
160
- if (!text) continue;
184
+ if (!text && files.length === 0) continue;
161
185
  const user = typeof m.user === "string" && m.user.length > 0 ? m.user : undefined;
162
186
  const botId = typeof m.bot_id === "string" && m.bot_id.length > 0 ? m.bot_id : undefined;
163
187
  out.push({
@@ -165,6 +189,7 @@ export function renderMessages(raw: unknown, opts: { oldestFirst?: boolean } = {
165
189
  ts: (m.ts as string) ?? "",
166
190
  text,
167
191
  ...(botId !== undefined ? { bot: true } : user !== undefined ? { authorId: user } : {}),
192
+ ...(files.length > 0 ? { files } : {}),
168
193
  });
169
194
  }
170
195
  // conversations.history returns newest-first; replies returns oldest-first.