@copilotkit/channels-slack 0.1.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 +353 -0
  3. package/dist/__tests__/assistant.test.d.ts +2 -0
  4. package/dist/__tests__/assistant.test.d.ts.map +1 -0
  5. package/dist/__tests__/assistant.test.js +153 -0
  6. package/dist/__tests__/auto-close-streaming.test.d.ts +2 -0
  7. package/dist/__tests__/auto-close-streaming.test.d.ts.map +1 -0
  8. package/dist/__tests__/auto-close-streaming.test.js +139 -0
  9. package/dist/__tests__/built-ins.test.d.ts +2 -0
  10. package/dist/__tests__/built-ins.test.d.ts.map +1 -0
  11. package/dist/__tests__/built-ins.test.js +67 -0
  12. package/dist/__tests__/chunked-message-stream.test.d.ts +2 -0
  13. package/dist/__tests__/chunked-message-stream.test.d.ts.map +1 -0
  14. package/dist/__tests__/chunked-message-stream.test.js +226 -0
  15. package/dist/__tests__/conversation-store.test.d.ts +2 -0
  16. package/dist/__tests__/conversation-store.test.d.ts.map +1 -0
  17. package/dist/__tests__/conversation-store.test.js +209 -0
  18. package/dist/__tests__/download-files.test.d.ts +2 -0
  19. package/dist/__tests__/download-files.test.d.ts.map +1 -0
  20. package/dist/__tests__/download-files.test.js +142 -0
  21. package/dist/__tests__/ephemeral.test.d.ts +2 -0
  22. package/dist/__tests__/ephemeral.test.d.ts.map +1 -0
  23. package/dist/__tests__/ephemeral.test.js +85 -0
  24. package/dist/__tests__/event-renderer.test.d.ts +2 -0
  25. package/dist/__tests__/event-renderer.test.d.ts.map +1 -0
  26. package/dist/__tests__/event-renderer.test.js +521 -0
  27. package/dist/__tests__/markdown-to-mrkdwn.test.d.ts +2 -0
  28. package/dist/__tests__/markdown-to-mrkdwn.test.d.ts.map +1 -0
  29. package/dist/__tests__/markdown-to-mrkdwn.test.js +80 -0
  30. package/dist/__tests__/message-stream.test.d.ts +2 -0
  31. package/dist/__tests__/message-stream.test.d.ts.map +1 -0
  32. package/dist/__tests__/message-stream.test.js +116 -0
  33. package/dist/__tests__/native-renderer.test.d.ts +2 -0
  34. package/dist/__tests__/native-renderer.test.d.ts.map +1 -0
  35. package/dist/__tests__/native-renderer.test.js +236 -0
  36. package/dist/__tests__/native-stream.test.d.ts +2 -0
  37. package/dist/__tests__/native-stream.test.d.ts.map +1 -0
  38. package/dist/__tests__/native-stream.test.js +235 -0
  39. package/dist/__tests__/reactions.test.d.ts +2 -0
  40. package/dist/__tests__/reactions.test.d.ts.map +1 -0
  41. package/dist/__tests__/reactions.test.js +193 -0
  42. package/dist/__tests__/slack-listener.test.d.ts +2 -0
  43. package/dist/__tests__/slack-listener.test.d.ts.map +1 -0
  44. package/dist/__tests__/slack-listener.test.js +559 -0
  45. package/dist/__tests__/views.test.d.ts +2 -0
  46. package/dist/__tests__/views.test.d.ts.map +1 -0
  47. package/dist/__tests__/views.test.js +143 -0
  48. package/dist/adapter.d.ts +188 -0
  49. package/dist/adapter.d.ts.map +1 -0
  50. package/dist/adapter.js +859 -0
  51. package/dist/adapter.test.d.ts +2 -0
  52. package/dist/adapter.test.d.ts.map +1 -0
  53. package/dist/adapter.test.js +368 -0
  54. package/dist/assistant.d.ts +19 -0
  55. package/dist/assistant.d.ts.map +1 -0
  56. package/dist/assistant.js +124 -0
  57. package/dist/auto-close-streaming.d.ts +62 -0
  58. package/dist/auto-close-streaming.d.ts.map +1 -0
  59. package/dist/auto-close-streaming.js +273 -0
  60. package/dist/built-in-context.d.ts +22 -0
  61. package/dist/built-in-context.d.ts.map +1 -0
  62. package/dist/built-in-context.js +65 -0
  63. package/dist/built-in-tools.d.ts +23 -0
  64. package/dist/built-in-tools.d.ts.map +1 -0
  65. package/dist/built-in-tools.js +45 -0
  66. package/dist/chunked-message-stream.d.ts +67 -0
  67. package/dist/chunked-message-stream.d.ts.map +1 -0
  68. package/dist/chunked-message-stream.js +134 -0
  69. package/dist/codec.d.ts +18 -0
  70. package/dist/codec.d.ts.map +1 -0
  71. package/dist/codec.js +18 -0
  72. package/dist/codec.test.d.ts +2 -0
  73. package/dist/codec.test.d.ts.map +1 -0
  74. package/dist/codec.test.js +19 -0
  75. package/dist/conversation-store.d.ts +92 -0
  76. package/dist/conversation-store.d.ts.map +1 -0
  77. package/dist/conversation-store.js +196 -0
  78. package/dist/download-files.d.ts +59 -0
  79. package/dist/download-files.d.ts.map +1 -0
  80. package/dist/download-files.js +116 -0
  81. package/dist/event-renderer.d.ts +98 -0
  82. package/dist/event-renderer.d.ts.map +1 -0
  83. package/dist/event-renderer.js +493 -0
  84. package/dist/index.d.ts +34 -0
  85. package/dist/index.d.ts.map +1 -0
  86. package/dist/index.js +23 -0
  87. package/dist/ingress-normalize.d.ts +75 -0
  88. package/dist/ingress-normalize.d.ts.map +1 -0
  89. package/dist/ingress-normalize.js +153 -0
  90. package/dist/ingress-normalize.test.d.ts +2 -0
  91. package/dist/ingress-normalize.test.d.ts.map +1 -0
  92. package/dist/ingress-normalize.test.js +187 -0
  93. package/dist/interaction.d.ts +30 -0
  94. package/dist/interaction.d.ts.map +1 -0
  95. package/dist/interaction.js +219 -0
  96. package/dist/interaction.test.d.ts +2 -0
  97. package/dist/interaction.test.d.ts.map +1 -0
  98. package/dist/interaction.test.js +137 -0
  99. package/dist/markdown-to-mrkdwn.d.ts +21 -0
  100. package/dist/markdown-to-mrkdwn.d.ts.map +1 -0
  101. package/dist/markdown-to-mrkdwn.js +99 -0
  102. package/dist/message-stream.d.ts +44 -0
  103. package/dist/message-stream.d.ts.map +1 -0
  104. package/dist/message-stream.js +70 -0
  105. package/dist/native-stream.d.ts +131 -0
  106. package/dist/native-stream.d.ts.map +1 -0
  107. package/dist/native-stream.js +208 -0
  108. package/dist/render/block-kit.d.ts +35 -0
  109. package/dist/render/block-kit.d.ts.map +1 -0
  110. package/dist/render/block-kit.js +399 -0
  111. package/dist/render/block-kit.test.d.ts +2 -0
  112. package/dist/render/block-kit.test.d.ts.map +1 -0
  113. package/dist/render/block-kit.test.js +289 -0
  114. package/dist/render/budget.d.ts +24 -0
  115. package/dist/render/budget.d.ts.map +1 -0
  116. package/dist/render/budget.js +30 -0
  117. package/dist/render/budget.test.d.ts +2 -0
  118. package/dist/render/budget.test.d.ts.map +1 -0
  119. package/dist/render/budget.test.js +49 -0
  120. package/dist/render/modal.d.ts +13 -0
  121. package/dist/render/modal.d.ts.map +1 -0
  122. package/dist/render/modal.js +108 -0
  123. package/dist/render/transport.d.ts +42 -0
  124. package/dist/render/transport.d.ts.map +1 -0
  125. package/dist/render/transport.js +1 -0
  126. package/dist/render.d.ts +23 -0
  127. package/dist/render.d.ts.map +1 -0
  128. package/dist/render.js +20 -0
  129. package/dist/sanitizing-http-agent.d.ts +28 -0
  130. package/dist/sanitizing-http-agent.d.ts.map +1 -0
  131. package/dist/sanitizing-http-agent.js +51 -0
  132. package/dist/slack-listener.d.ts +73 -0
  133. package/dist/slack-listener.d.ts.map +1 -0
  134. package/dist/slack-listener.js +126 -0
  135. package/dist/slack-listener.test.d.ts +2 -0
  136. package/dist/slack-listener.test.d.ts.map +1 -0
  137. package/dist/slack-listener.test.js +115 -0
  138. package/dist/types.d.ts +153 -0
  139. package/dist/types.d.ts.map +1 -0
  140. package/dist/types.js +20 -0
  141. package/package.json +73 -0
@@ -0,0 +1,859 @@
1
+ import { App, LogLevel } from "@slack/bolt";
2
+ import { toPlatformEmoji } from "@copilotkit/channels-ui";
3
+ import { SlackConversationStore } from "./conversation-store.js";
4
+ import { attachSlackListener } from "./slack-listener.js";
5
+ import { createRunRenderer } from "./event-renderer.js";
6
+ import { decodeInteraction, decodeReaction, decodeViewSubmission, decodeViewClosed, conversationKeyOf, } from "./interaction.js";
7
+ import { renderBlockKit, renderSlackMessage, buildFeedbackBlocks, FEEDBACK_ACTION_ID, } from "./render/block-kit.js";
8
+ import { renderSlackModal } from "./render/modal.js";
9
+ import { ChunkedMessageStream } from "./chunked-message-stream.js";
10
+ import { NativeMessageStream } from "./native-stream.js";
11
+ import { attachAssistant } from "./assistant.js";
12
+ import { autoCloseOpenMarkdown } from "./auto-close-streaming.js";
13
+ import { markdownToMrkdwn } from "./markdown-to-mrkdwn.js";
14
+ import { DM_SCOPE, resolveSlackRespondToOptions } from "./types.js";
15
+ /** Slack `PlatformAdapter`: ingress via Bolt, egress via Block Kit + streaming. */
16
+ export class SlackAdapter {
17
+ opts;
18
+ platform = "slack";
19
+ capabilities;
20
+ ackDeadlineMs = 3000;
21
+ app;
22
+ client;
23
+ botUserId = "";
24
+ store;
25
+ sink;
26
+ /** Per-id cache for sender-profile resolution (repeat turns are cheap). */
27
+ userCache = new Map();
28
+ /** Set once the Assistant middleware is attached (when assistant !== false). */
29
+ assistantHandle;
30
+ /** Our team id (from auth.test); native channel streams need it. */
31
+ teamId;
32
+ /**
33
+ * In-memory native-streaming health for this workspace. Flipped to false the
34
+ * first time `chat.startStream` fails, so subsequent streams skip the native
35
+ * path and go straight to the legacy transport.
36
+ */
37
+ nativeStreamingOk = true;
38
+ /**
39
+ * In-memory health of native structured `task_update` chunks for this
40
+ * workspace. Flipped to false the first time a chunk append fails (old
41
+ * workspace / missing `assistant:write`), so later turns surface tool
42
+ * progress as `:wrench:` rows instead of retrying chunks.
43
+ */
44
+ nativeTaskChunksOk = true;
45
+ constructor(opts) {
46
+ this.opts = opts;
47
+ const assistantEnabled = opts.assistant !== false;
48
+ this.capabilities = {
49
+ supportsModals: true,
50
+ supportsTyping: false,
51
+ supportsReactions: true,
52
+ supportsStreaming: true,
53
+ supportsEphemeral: true,
54
+ maxBlocksPerMessage: 50,
55
+ supportsSuggestedPrompts: assistantEnabled,
56
+ supportsThreadTitle: assistantEnabled,
57
+ };
58
+ this.app = new App({
59
+ token: opts.botToken,
60
+ appToken: opts.appToken,
61
+ signingSecret: opts.signingSecret,
62
+ socketMode: opts.socketMode ?? true,
63
+ logLevel: opts.logLevel ?? LogLevel.INFO,
64
+ // Without this, Bolt's constructor fires a background auth.test that
65
+ // can't be awaited or error-handled (and phones home from unit tests).
66
+ // start() owns initialization: app.init() below, then our own awaited
67
+ // auth.test — construction stays side-effect-free.
68
+ deferInitialization: true,
69
+ });
70
+ this.client = this.app.client;
71
+ this.store = new SlackConversationStore({
72
+ client: this.client,
73
+ botUserId: "",
74
+ botToken: opts.botToken,
75
+ });
76
+ }
77
+ async start(sink) {
78
+ this.sink = sink;
79
+ // Deferred from the constructor (see above); Bolt requires init() before
80
+ // app.start(), and doing it here surfaces auth/config errors to the caller.
81
+ await this.app.init();
82
+ // Resolve our own bot user id before attaching the listener so the loop
83
+ // guard (skip our own posts) is in place from the first event.
84
+ const auth = await this.client.auth.test();
85
+ this.botUserId = auth.user_id;
86
+ this.teamId = auth.team_id;
87
+ this.store.botUserId = this.botUserId;
88
+ // Attach the assistant-pane middleware FIRST (when enabled) so its
89
+ // `isAssistantThread` predicate is available to the message listener's
90
+ // no-double-delivery guard below.
91
+ if (this.opts.assistant !== false) {
92
+ this.assistantHandle = attachAssistant({
93
+ app: this.app,
94
+ sink,
95
+ opts: this.opts.assistant ?? {},
96
+ resolveUser: (id) => this.resolveUser(id),
97
+ });
98
+ }
99
+ attachSlackListener({
100
+ app: this.app,
101
+ store: this.store,
102
+ botUserId: this.botUserId,
103
+ respondTo: resolveSlackRespondToOptions(this.opts.respondTo),
104
+ isAssistantThread: this.assistantHandle?.isAssistantThread,
105
+ onTurn: async (turn) => {
106
+ await sink.onTurn({
107
+ conversationKey: conversationKeyOf(turn.conversation),
108
+ // Carry the sender id so native channel streams can pass
109
+ // `recipient_user_id` to chat.startStream.
110
+ replyTarget: {
111
+ ...turn.replyTarget,
112
+ recipientUserId: turn.senderUserId,
113
+ },
114
+ userText: turn.userText,
115
+ user: turn.senderUserId
116
+ ? await this.resolveUser(turn.senderUserId)
117
+ : undefined,
118
+ // Stable per-delivery id for inbound dedup (Events API event_id, or a
119
+ // fallback derived by the listener); undefined when unavailable.
120
+ eventId: turn.eventId,
121
+ platform: "slack",
122
+ });
123
+ },
124
+ onCommand: async (cmd) => {
125
+ await sink.onCommand({
126
+ command: cmd.command,
127
+ text: cmd.text,
128
+ // Slack delivers args as free text only; structured `rawOptions`
129
+ // are a Discord-style capability, so they're left unset here.
130
+ conversationKey: conversationKeyOf(cmd.conversation),
131
+ replyTarget: cmd.replyTarget,
132
+ user: cmd.senderUserId
133
+ ? await this.resolveUser(cmd.senderUserId)
134
+ : undefined,
135
+ // Stable per-invocation id for inbound dedup (command:user:trigger_id).
136
+ eventId: cmd.eventId,
137
+ platform: "slack",
138
+ triggerId: cmd.triggerId,
139
+ });
140
+ },
141
+ });
142
+ // Every block_actions click → decode to an opaque-id InteractionEvent and
143
+ // hand to the sink. The matching `ck:` action either resolves an awaiting
144
+ // HITL picker or dispatches via the ActionRegistry; unrelated clicks decode
145
+ // to events the bot harmlessly ignores.
146
+ this.app.action(/.*/, async ({ ack, body }) => {
147
+ await ack();
148
+ // Native feedback-row clicks are handled adapter-locally and never reach
149
+ // the engine's interaction dispatch (which would swallow the unknown id).
150
+ if (this.handleFeedbackClick(body))
151
+ return;
152
+ const evt = this.decodeInteraction(body);
153
+ if (evt)
154
+ await sink.onInteraction(evt);
155
+ });
156
+ this.app.event("reaction_added", async ({ event }) => {
157
+ // Loop guard: Slack delivers reaction events for the bot's OWN reactions
158
+ // (e.g. our addReaction egress), which would echo back as phantom user
159
+ // reactions. Skip them, like every other ingress path guards botUserId.
160
+ if (event.user === this.botUserId)
161
+ return;
162
+ const evt = decodeReaction(event, true);
163
+ if (!evt)
164
+ return;
165
+ // Enrich the reactor to parity with onTurn/onCommand so per-user
166
+ // attribution (e.g. senderContext(evt.user) → filter by email) works
167
+ // for reaction-triggered runs, not just mentions and commands.
168
+ if (event.user)
169
+ evt.user = await this.resolveUser(event.user);
170
+ await sink.onReaction(evt);
171
+ });
172
+ this.app.event("reaction_removed", async ({ event }) => {
173
+ if (event.user === this.botUserId)
174
+ return;
175
+ const evt = decodeReaction(event, false);
176
+ if (!evt)
177
+ return;
178
+ if (event.user)
179
+ evt.user = await this.resolveUser(event.user);
180
+ await sink.onReaction(evt);
181
+ });
182
+ // Modal submit: route to the engine and ack. When the handler returns
183
+ // per-field errors, ack with `response_action: "errors"` (keeps the modal
184
+ // open with inline messages); otherwise a plain ack closes the modal.
185
+ // A catch-all `/.*/` callback_id constraint defaults to `view_submission`.
186
+ this.app.view(/.*/, async ({ ack, body, view }) => {
187
+ const user = body.user?.id
188
+ ? { id: body.user.id, name: body.user.name }
189
+ : undefined;
190
+ const result = await sink.onModalSubmit(decodeViewSubmission(view, user));
191
+ if (result?.errors) {
192
+ await ack({ response_action: "errors", errors: result.errors });
193
+ }
194
+ else {
195
+ await ack();
196
+ }
197
+ });
198
+ // Modal dismissed (only delivered when the view set `notify_on_close`).
199
+ this.app.view({ type: "view_closed", callback_id: /.*/ }, async ({ ack, body, view }) => {
200
+ const user = body.user?.id
201
+ ? { id: body.user.id, name: body.user.name }
202
+ : undefined;
203
+ await sink.onModalClose(decodeViewClosed(view, user));
204
+ await ack();
205
+ });
206
+ // Socket Mode ignores the port; HTTP mode binds it.
207
+ await this.app.start(this.opts.port ?? 0);
208
+ }
209
+ async stop() {
210
+ await this.app.stop();
211
+ }
212
+ render(ir) {
213
+ return renderBlockKit(ir);
214
+ }
215
+ async post(target, ir) {
216
+ const t = target;
217
+ const { blocks, accent } = renderSlackMessage(ir);
218
+ const summary = fallbackText(ir);
219
+ // Suppress Slack link/media unfurling: a card with many links (e.g. an
220
+ // issue_list of Linear URLs) would otherwise spawn a wall of preview
221
+ // attachments. The gen-UI card IS the presentation.
222
+ const base = {
223
+ channel: t.channel,
224
+ thread_ts: t.threadTs,
225
+ unfurl_links: false,
226
+ unfurl_media: false,
227
+ // Short one-line notification/a11y fallback. Slack does NOT render a
228
+ // top-level `text` as the message body when `blocks`/`attachments` are
229
+ // present, so this is safe on both paths.
230
+ text: summary,
231
+ };
232
+ // ACCENT path: render the colored attachment card. The attachment carries
233
+ // ONLY `{ color, blocks }` — adding a legacy `fallback` field alongside
234
+ // `blocks` makes Slack reject the payload with `invalid_attachments`.
235
+ const args = accent
236
+ ? { ...base, attachments: [{ color: accent, blocks }] }
237
+ : { ...base, blocks };
238
+ const res = await this.client.chat.postMessage(args);
239
+ return { id: res.ts, channel: t.channel, ts: res.ts };
240
+ }
241
+ async update(ref, ir) {
242
+ const channel = channelOf(ref);
243
+ const { blocks, accent } = renderSlackMessage(ir);
244
+ const summary = fallbackText(ir);
245
+ // Mirror `post`'s accent/non-accent split. `chat.update` does not accept
246
+ // the `unfurl_*` flags, so they are only set on `postMessage`.
247
+ const args = accent
248
+ ? {
249
+ channel,
250
+ ts: ref.id,
251
+ text: summary,
252
+ attachments: [{ color: accent, blocks }],
253
+ }
254
+ : {
255
+ channel,
256
+ ts: ref.id,
257
+ text: summary,
258
+ blocks,
259
+ };
260
+ await this.client.chat.update(args);
261
+ }
262
+ async stream(target, chunks) {
263
+ const t = target;
264
+ let firstTs;
265
+ let channel = t.channel;
266
+ // The shipped chat.update streamer — also the automatic fallback for native.
267
+ const makeLegacy = () => new ChunkedMessageStream({
268
+ postPlaceholder: async (text) => {
269
+ const posted = await this.client.chat.postMessage({
270
+ channel: t.channel,
271
+ thread_ts: t.threadTs,
272
+ text,
273
+ unfurl_links: false,
274
+ unfurl_media: false,
275
+ });
276
+ if (!posted.ts)
277
+ throw new Error("postMessage returned no ts");
278
+ if (!firstTs) {
279
+ firstTs = posted.ts;
280
+ channel = posted.channel ?? t.channel;
281
+ }
282
+ return posted.ts;
283
+ },
284
+ updateAt: async (ts, text) => {
285
+ await this.client.chat.update({ channel: t.channel, ts, text });
286
+ },
287
+ transform: (s) => markdownToMrkdwn(autoCloseOpenMarkdown(s)),
288
+ });
289
+ // Native streaming only where a thread exists (Slack requires streams to be
290
+ // thread replies); flat DMs always use the legacy streamer.
291
+ const useNative = this.opts.streaming !== "legacy" &&
292
+ !!t.threadTs &&
293
+ this.nativeStreamingOk;
294
+ let sink;
295
+ if (useNative) {
296
+ sink = new NativeMessageStream({
297
+ transport: this.nativeTransport(t, (ts, ch) => {
298
+ if (!firstTs) {
299
+ firstTs = ts;
300
+ channel = ch ?? t.channel;
301
+ }
302
+ }),
303
+ fallback: makeLegacy,
304
+ onStartFailure: () => {
305
+ this.nativeStreamingOk = false;
306
+ },
307
+ });
308
+ }
309
+ else {
310
+ sink = makeLegacy();
311
+ }
312
+ let acc = "";
313
+ for await (const chunk of chunks) {
314
+ acc += chunk;
315
+ sink.append(acc);
316
+ }
317
+ await sink.finish();
318
+ return { id: firstTs ?? "", channel, ts: firstTs };
319
+ }
320
+ /**
321
+ * Build the {@link NativeStreamTransport} (chat.startStream/appendStream/
322
+ * stopStream) for a thread target. `onFirstTs` records the first streamed
323
+ * message's ts/channel for the returned MessageRef. Native channel streams
324
+ * pass `recipient_user_id` (the turn sender) + `recipient_team_id`.
325
+ */
326
+ nativeTransport(t, onFirstTs) {
327
+ const threadTs = t.threadTs;
328
+ if (!threadTs) {
329
+ // Native streaming is only wired up where a thread exists (Slack requires
330
+ // streams to be thread replies); the callers gate on this already.
331
+ throw new Error("native streaming requires a thread_ts");
332
+ }
333
+ // `recipient_user_id` / `recipient_team_id` are required when streaming to a
334
+ // channel and implicit in DMs / assistant threads — pass them only for
335
+ // non-DM targets (DM channel ids start with "D").
336
+ const isChannel = !t.channel.startsWith("D");
337
+ return {
338
+ startStream: async () => {
339
+ const args = {
340
+ channel: t.channel,
341
+ thread_ts: threadTs,
342
+ task_display_mode: "timeline",
343
+ ...(isChannel && t.recipientUserId
344
+ ? { recipient_user_id: t.recipientUserId }
345
+ : {}),
346
+ ...(isChannel && this.teamId
347
+ ? { recipient_team_id: this.teamId }
348
+ : {}),
349
+ };
350
+ const res = await this.client.chat.startStream(args);
351
+ if (!res.ts)
352
+ throw new Error("startStream returned no ts");
353
+ onFirstTs(res.ts, res.channel);
354
+ return res.ts;
355
+ },
356
+ appendText: async (ts, markdownText) => {
357
+ const args = {
358
+ channel: t.channel,
359
+ ts,
360
+ markdown_text: markdownText,
361
+ };
362
+ await this.client.chat.appendStream(args);
363
+ },
364
+ appendChunks: async (ts, chunks) => {
365
+ const args = {
366
+ channel: t.channel,
367
+ ts,
368
+ chunks,
369
+ };
370
+ await this.client.chat.appendStream(args);
371
+ },
372
+ stopStream: async (ts, finalBlocks) => {
373
+ const args = {
374
+ channel: t.channel,
375
+ ts,
376
+ ...(finalBlocks && finalBlocks.length > 0
377
+ ? { blocks: finalBlocks }
378
+ : {}),
379
+ };
380
+ await this.client.chat.stopStream(args);
381
+ },
382
+ };
383
+ }
384
+ /**
385
+ * The credentialed {@link SlackRenderTransport} the run renderer calls
386
+ * (setStatus / postMessage / update), wrapping this adapter's `WebClient`.
387
+ * Extracted so `createRunRenderer` stays Bolt-free and the managed Connector
388
+ * Outbox can drive the identical renderer with its own sender.
389
+ */
390
+ renderTransport() {
391
+ return {
392
+ setStatus: async (a) => {
393
+ await this.client.assistant.threads.setStatus(a);
394
+ },
395
+ postMessage: async (a) => {
396
+ const res = await this.client.chat.postMessage(a);
397
+ return { ts: res.ts };
398
+ },
399
+ updateMessage: async (a) => {
400
+ await this.client.chat.update(a);
401
+ },
402
+ };
403
+ }
404
+ /**
405
+ * Route a `feedback_buttons` click to the configured feedback callback.
406
+ * Returns `true` if this was a feedback click (so the caller skips the
407
+ * engine's interaction dispatch). Best-effort: payload-shape or handler
408
+ * errors are logged, never thrown.
409
+ */
410
+ handleFeedbackClick(raw) {
411
+ const feedback = this.opts.feedback;
412
+ if (!feedback)
413
+ return false;
414
+ const body = raw;
415
+ const action = body.actions?.[0];
416
+ if (action?.action_id !== FEEDBACK_ACTION_ID)
417
+ return false;
418
+ const sentiment = action.value === "negative" ? "negative" : "positive";
419
+ const channel = body.channel?.id ?? body.container?.channel_id;
420
+ const messageTs = body.message?.ts;
421
+ if (!channel || !messageTs) {
422
+ // It's a feedback click (so we still swallow it rather than forwarding to
423
+ // the engine), but the payload lacked the refs the handler needs.
424
+ console.warn("[slack-adapter] feedback click missing channel/message ts; ignoring");
425
+ return true;
426
+ }
427
+ void Promise.resolve(feedback.onFeedback({
428
+ sentiment,
429
+ channel,
430
+ messageTs,
431
+ threadTs: body.message?.thread_ts,
432
+ user: body.user?.id
433
+ ? { id: body.user.id, name: body.user.name ?? body.user.username }
434
+ : undefined,
435
+ })).catch((err) => console.error("[slack-adapter] onFeedback handler failed:", err));
436
+ return true;
437
+ }
438
+ async delete(ref) {
439
+ await this.client.chat.delete({ channel: channelOf(ref), ts: ref.id });
440
+ }
441
+ /**
442
+ * True if `target` is a known assistant-pane thread (recorded by the
443
+ * Assistant middleware). Pane targets drive native status and back the
444
+ * pane-only `setSuggestedPrompts` / `setThreadTitle` methods.
445
+ */
446
+ isPaneTarget(t) {
447
+ return Boolean(t.threadTs &&
448
+ this.assistantHandle?.isAssistantThread(t.channel, t.threadTs));
449
+ }
450
+ createRunRenderer(target) {
451
+ const t = target;
452
+ const assistantOpts = this.opts.assistant === false ? undefined : (this.opts.assistant ?? {});
453
+ const isPane = this.isPaneTarget(t);
454
+ // Native `setStatus` ("is thinking…") works for any thread we can anchor it
455
+ // to — pane, channel @-mention, tracked channel thread, or (via the carried
456
+ // inbound ts) a flat DM. `assistant: false` opts out everywhere.
457
+ const statusThreadTs = t.threadTs ?? t.statusTs;
458
+ const status = assistantOpts && statusThreadTs
459
+ ? {
460
+ threadTs: statusThreadTs,
461
+ isPane,
462
+ config: assistantOpts.status ?? {},
463
+ }
464
+ : undefined;
465
+ // Native streaming wherever a thread exists (and the workspace supports it).
466
+ const useNative = this.opts.streaming !== "legacy" &&
467
+ !!t.threadTs &&
468
+ this.nativeStreamingOk;
469
+ return createRunRenderer({
470
+ transport: this.renderTransport(),
471
+ target: t,
472
+ interruptEventNames: this.opts.interruptEventNames,
473
+ showToolStatus: this.opts.showToolStatus,
474
+ status,
475
+ nativeStreaming: useNative
476
+ ? {
477
+ transport: this.nativeTransport(t, () => { }),
478
+ onStartFailure: () => {
479
+ this.nativeStreamingOk = false;
480
+ },
481
+ // Pane threads keep composer status for tool progress; elsewhere
482
+ // surface it as in-message task_update chunks (when supported).
483
+ taskChunks: !isPane && this.nativeTaskChunksOk,
484
+ onChunkFailure: () => {
485
+ this.nativeTaskChunksOk = false;
486
+ },
487
+ }
488
+ : undefined,
489
+ // Native AI feedback row (opt-in); only attached to native streamed
490
+ // replies, so omit it on the legacy path.
491
+ feedbackBlocks: useNative && this.opts.feedback
492
+ ? buildFeedbackBlocks({
493
+ positiveLabel: this.opts.feedback.positiveLabel,
494
+ negativeLabel: this.opts.feedback.negativeLabel,
495
+ })
496
+ : undefined,
497
+ });
498
+ }
499
+ /** Backs the capability-gated `Thread.setSuggestedPrompts` for pane threads. */
500
+ async setSuggestedPrompts(target, prompts, opts) {
501
+ const t = target;
502
+ if (!this.isPaneTarget(t)) {
503
+ return {
504
+ ok: false,
505
+ error: "suggested prompts require an assistant-pane thread",
506
+ };
507
+ }
508
+ try {
509
+ await this.client.assistant.threads.setSuggestedPrompts({
510
+ channel_id: t.channel,
511
+ thread_ts: t.threadTs,
512
+ prompts: prompts.map((p) => ({
513
+ title: p.title,
514
+ message: p.message,
515
+ })),
516
+ ...(opts?.title ? { title: opts.title } : {}),
517
+ });
518
+ return { ok: true };
519
+ }
520
+ catch (e) {
521
+ return { ok: false, error: e.message };
522
+ }
523
+ }
524
+ /** Backs the capability-gated `Thread.setTitle` for pane threads. */
525
+ async setThreadTitle(target, title) {
526
+ const t = target;
527
+ if (!this.isPaneTarget(t)) {
528
+ return {
529
+ ok: false,
530
+ error: "thread title requires an assistant-pane thread",
531
+ };
532
+ }
533
+ try {
534
+ await this.client.assistant.threads.setTitle({
535
+ channel_id: t.channel,
536
+ thread_ts: t.threadTs,
537
+ title,
538
+ });
539
+ return { ok: true };
540
+ }
541
+ catch (e) {
542
+ return { ok: false, error: e.message };
543
+ }
544
+ }
545
+ decodeInteraction(raw) {
546
+ return decodeInteraction(raw);
547
+ }
548
+ async lookupUser(q) {
549
+ const query = q.query.trim().toLowerCase();
550
+ if (!query)
551
+ return undefined;
552
+ try {
553
+ let cursor;
554
+ do {
555
+ const r = (await this.client.users.list({ cursor, limit: 200 }));
556
+ for (const m of r.members ?? []) {
557
+ if (!m.id || m.deleted || m.is_bot)
558
+ continue;
559
+ const candidates = [
560
+ m.name,
561
+ m.real_name,
562
+ m.profile?.display_name,
563
+ m.profile?.email,
564
+ ]
565
+ .filter((s) => Boolean(s))
566
+ .map((s) => s.toLowerCase());
567
+ if (candidates.some((c) => c === query || c.startsWith(query))) {
568
+ return {
569
+ id: m.id,
570
+ name: m.real_name ?? m.name,
571
+ handle: m.name,
572
+ email: m.profile?.email,
573
+ };
574
+ }
575
+ }
576
+ cursor = r.response_metadata?.next_cursor || undefined;
577
+ } while (cursor);
578
+ }
579
+ catch {
580
+ return undefined;
581
+ }
582
+ return undefined;
583
+ }
584
+ /**
585
+ * Resolve a Slack user id to a richer `PlatformUser` (name + email) for each
586
+ * turn, cached by id so repeat turns in the same conversation are cheap.
587
+ * Tolerates lookup failure by falling back to a bare `{ id }`.
588
+ */
589
+ async resolveUser(userId) {
590
+ const cached = this.userCache.get(userId);
591
+ if (cached)
592
+ return cached;
593
+ let user = { id: userId };
594
+ try {
595
+ const r = (await this.client.users.info({ user: userId }));
596
+ const u = r.user;
597
+ if (u?.id) {
598
+ user = {
599
+ id: u.id,
600
+ name: u.real_name ??
601
+ u.profile?.real_name ??
602
+ u.profile?.display_name ??
603
+ u.name,
604
+ email: u.profile?.email,
605
+ };
606
+ }
607
+ }
608
+ catch {
609
+ // Fall back to the bare id on any lookup failure.
610
+ }
611
+ this.userCache.set(userId, user);
612
+ return user;
613
+ }
614
+ get conversationStore() {
615
+ const store = this.store;
616
+ return {
617
+ async getOrCreate(conversationKey, replyTarget, makeAgent) {
618
+ const idx = conversationKey.indexOf("::");
619
+ const channelId = idx >= 0 ? conversationKey.slice(0, idx) : conversationKey;
620
+ const scope = idx >= 0 ? conversationKey.slice(idx + 2) : DM_SCOPE;
621
+ const key = { channelId, scope };
622
+ const session = await store.getOrCreate(key, replyTarget, makeAgent);
623
+ return { agent: session.agent };
624
+ },
625
+ };
626
+ }
627
+ /**
628
+ * Read the conversation's recent messages. Backs the capability-gated
629
+ * `Thread.getMessages()`. For a thread target we read its replies; a flat
630
+ * target (DM, no `threadTs`) has no thread to fetch, so we return `[]`.
631
+ * Capped defensively to the last 100 messages.
632
+ */
633
+ async getMessages(target) {
634
+ const t = target;
635
+ const threadTs = t.threadTs;
636
+ if (!threadTs)
637
+ return [];
638
+ let messages = [];
639
+ try {
640
+ const r = (await this.client.conversations.replies({
641
+ channel: t.channel,
642
+ ts: threadTs,
643
+ limit: 100,
644
+ }));
645
+ messages = r.messages ?? [];
646
+ }
647
+ catch {
648
+ return [];
649
+ }
650
+ const out = [];
651
+ for (const m of messages.slice(-100)) {
652
+ // Skip Slack's own join/system subtype messages; keep regular messages
653
+ // and file shares.
654
+ if (m.subtype && m.subtype !== "file_share")
655
+ continue;
656
+ out.push({
657
+ text: m.text ?? "",
658
+ ts: m.ts,
659
+ isBot: Boolean(m.bot_id),
660
+ user: m.user ? await this.resolveUser(m.user) : undefined,
661
+ });
662
+ }
663
+ return out;
664
+ }
665
+ async postFile(target, { bytes, filename, title, altText, }) {
666
+ const t = target;
667
+ try {
668
+ // Slack's `FilesUploadV2Arguments` union types `thread_ts` as a
669
+ // required `string` when present; omit the key entirely (rather
670
+ // than passing `undefined`) under exactOptionalPropertyTypes.
671
+ const args = {
672
+ channel_id: t.channel,
673
+ file: Buffer.from(bytes),
674
+ filename,
675
+ title,
676
+ alt_text: altText,
677
+ };
678
+ if (t.threadTs)
679
+ args.thread_ts = t.threadTs;
680
+ await this.client.files.uploadV2(args);
681
+ return { ok: true };
682
+ }
683
+ catch (e) {
684
+ return { ok: false, error: e.message };
685
+ }
686
+ }
687
+ async addReaction(target, messageRef, emoji) {
688
+ const name = toPlatformEmoji(emoji, "slack") ?? emoji;
689
+ const channel = messageRef.channel ??
690
+ target.channel;
691
+ try {
692
+ await this.client.reactions.add({
693
+ channel,
694
+ timestamp: messageRef.id,
695
+ name,
696
+ });
697
+ return { ok: true };
698
+ }
699
+ catch (err) {
700
+ return { ok: false, error: err.message };
701
+ }
702
+ }
703
+ async removeReaction(target, messageRef, emoji) {
704
+ const name = toPlatformEmoji(emoji, "slack") ?? emoji;
705
+ const channel = messageRef.channel ??
706
+ target.channel;
707
+ try {
708
+ await this.client.reactions.remove({
709
+ channel,
710
+ timestamp: messageRef.id,
711
+ name,
712
+ });
713
+ return { ok: true };
714
+ }
715
+ catch (err) {
716
+ return { ok: false, error: err.message };
717
+ }
718
+ }
719
+ /**
720
+ * Post an ephemeral message visible only to `user`. Slack always supports
721
+ * native ephemeral, so `fallbackToDM` is ignored and `usedFallback` is
722
+ * always `false` on success. On API error returns `{ ok: false, error }`.
723
+ */
724
+ async postEphemeral(target, user, ir, _opts) {
725
+ const t = target;
726
+ const userId = typeof user === "string" ? user : user.id;
727
+ const { blocks } = renderSlackMessage(ir);
728
+ const text = fallbackText(ir);
729
+ try {
730
+ const res = await this.client.chat.postEphemeral({
731
+ channel: t.channel,
732
+ user: userId,
733
+ ...(t.threadTs ? { thread_ts: t.threadTs } : {}),
734
+ blocks,
735
+ text,
736
+ });
737
+ return {
738
+ ok: true,
739
+ usedFallback: false,
740
+ ref: {
741
+ id: res.message_ts ?? "",
742
+ channel: t.channel,
743
+ },
744
+ };
745
+ }
746
+ catch (err) {
747
+ return { ok: false, error: err.message };
748
+ }
749
+ }
750
+ /** Render a modal IR tree to a Slack `views.open` `View` (pure; backs `openModal`). */
751
+ renderModal(ir) {
752
+ return renderSlackModal(ir);
753
+ }
754
+ /**
755
+ * Open a modal against a Slack `trigger_id` via `views.open`. Degrades rather
756
+ * than throwing: a render error (unsupported element) or API failure (expired
757
+ * trigger, etc.) resolves to `{ ok: false, error }`.
758
+ */
759
+ async openModal(target, triggerId, ir) {
760
+ try {
761
+ const t = target;
762
+ const view = renderSlackModal(ir);
763
+ // Slack `view_submission`/`view_closed` payloads are detached from the
764
+ // originating channel, so private_metadata is the only carrier of the
765
+ // conversation context. Stamp a `__cpk` envelope with the reply target,
766
+ // preserving any author-set private_metadata under `pm` for round-trip.
767
+ view.private_metadata = JSON.stringify({
768
+ __cpk: {
769
+ channel: t.channel,
770
+ ...(t.threadTs ? { threadTs: t.threadTs } : {}),
771
+ },
772
+ ...(view.private_metadata !== undefined
773
+ ? { pm: view.private_metadata }
774
+ : {}),
775
+ });
776
+ await this.client.views.open({
777
+ trigger_id: triggerId,
778
+ view,
779
+ });
780
+ return { ok: true };
781
+ }
782
+ catch (err) {
783
+ return { ok: false, error: err.message };
784
+ }
785
+ }
786
+ }
787
+ /** Construct a Slack `PlatformAdapter`. */
788
+ export function slack(opts) {
789
+ return new SlackAdapter(opts);
790
+ }
791
+ /** Read the channel stashed on a MessageRef by `post`/`stream`. */
792
+ function channelOf(ref) {
793
+ const channel = ref.channel;
794
+ return typeof channel === "string" ? channel : "";
795
+ }
796
+ /** Collect a node's descendant text into a single whitespace-joined string. */
797
+ function collectNodeText(node) {
798
+ const acc = [];
799
+ const visit = (n) => {
800
+ if (typeof n.type === "string" && n.type === "text") {
801
+ const value = n.props?.value;
802
+ if (value != null)
803
+ acc.push(String(value));
804
+ return;
805
+ }
806
+ const children = n.props?.children;
807
+ const list = Array.isArray(children)
808
+ ? children
809
+ : children && typeof children === "object" && "type" in children
810
+ ? [children]
811
+ : [];
812
+ for (const child of list)
813
+ visit(child);
814
+ };
815
+ visit(node);
816
+ return acc.join(" ");
817
+ }
818
+ /** Depth-first search for the first node of `type` in the IR tree. */
819
+ function findFirst(ir, type) {
820
+ for (const node of ir) {
821
+ if (typeof node.type === "string" && node.type === type)
822
+ return node;
823
+ const children = node.props?.children;
824
+ const list = Array.isArray(children)
825
+ ? children
826
+ : children && typeof children === "object" && "type" in children
827
+ ? [children]
828
+ : [];
829
+ const found = findFirst(list, type);
830
+ if (found)
831
+ return found;
832
+ }
833
+ return undefined;
834
+ }
835
+ /**
836
+ * Slack requires a plain-text `text` fallback alongside `blocks`/`attachments`
837
+ * (used for notifications and a11y) — NOT a rendering of the card body. Return
838
+ * a concise one-line summary: the card's header (title) if present, else the
839
+ * first text encountered. Collapse whitespace and truncate to ~150 chars. This
840
+ * MUST stay short: it is the notification text, never a dump of the whole tree
841
+ * (which Slack would render as a duplicate "text wall" above the card).
842
+ */
843
+ function fallbackText(ir) {
844
+ const header = findFirst(ir, "header");
845
+ const source = header ? collectNodeText(header) : firstText(ir);
846
+ const text = source.replace(/\s+/g, " ").trim();
847
+ if (!text)
848
+ return "…";
849
+ return text.length > 150 ? text.slice(0, 149) + "…" : text;
850
+ }
851
+ /** First descendant text node's value across the whole IR, or "". */
852
+ function firstText(ir) {
853
+ for (const node of ir) {
854
+ const t = collectNodeText(node);
855
+ if (t.trim())
856
+ return t;
857
+ }
858
+ return "";
859
+ }