@mono-agent/slack-adapter 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/adapter.js CHANGED
@@ -1,5 +1,17 @@
1
1
  import { isAgentResponseCancelledError, } from "@mono-agent/agent-contracts";
2
2
  import { SlackMessageStream, } from "./message-stream.js";
3
+ const DEFAULT_ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024;
4
+ const DEFAULT_ALLOWED_MIME_TYPES = [
5
+ "image/png",
6
+ "image/jpeg",
7
+ "image/gif",
8
+ "image/webp",
9
+ "application/pdf",
10
+ "text/plain",
11
+ "text/markdown",
12
+ "text/csv",
13
+ "application/json",
14
+ ];
3
15
  const DEFAULT_MESSAGES = {
4
16
  welcomeText: "Hello! Send me a Slack message and I will pass it to the configured agent.",
5
17
  helpText: "Send a Slack DM or mention the app in a channel. Use /cancel in a thread to stop the current response.",
@@ -9,6 +21,66 @@ const DEFAULT_MESSAGES = {
9
21
  errorText: "The agent failed while processing your Slack message.",
10
22
  unsupportedText: "I can only handle Slack text messages in this adapter for now.",
11
23
  };
24
+ // Slack delivers file uploads as subtyped messages (`file_share`), and a message
25
+ // posted to a thread "also sent to channel" arrives as `thread_broadcast`. Both
26
+ // carry real user content (text and/or a `files` array), so they must NOT be
27
+ // rejected alongside genuinely-unsupported subtypes (e.g. message_changed,
28
+ // channel_join). Without this, attachments are silently dropped.
29
+ const FILE_BEARING_MESSAGE_SUBTYPES = new Set([
30
+ "file_share",
31
+ "thread_broadcast",
32
+ ]);
33
+ // Mirrors the harness LiveSessionManager's DEFAULT_MAX_PENDING_PER_CONVERSATION:
34
+ // the per-conversation admission queue rejects past this depth so a flood of
35
+ // same-conversation messages cannot grow the queue unbounded.
36
+ const DEFAULT_ADMISSION_QUEUE_MAX_DEPTH = 100;
37
+ /**
38
+ * Thrown synchronously by {@link SerialQueue.run} when the queue is already at
39
+ * its depth cap. The adapter catches this sentinel to answer with the busy
40
+ * terminal instead of admitting an unbounded backlog.
41
+ */
42
+ export class SerialQueueFullError extends Error {
43
+ code = "serial_queue_full";
44
+ constructor(maxDepth) {
45
+ super(`Per-conversation admission queue is full (max ${maxDepth} pending).`);
46
+ this.name = "SerialQueueFullError";
47
+ }
48
+ }
49
+ /**
50
+ * Minimal per-conversation serial queue: each submitted task runs only after the
51
+ * previous one settles, preserving arrival order. A task's failure does not
52
+ * poison the queue (the chain swallows it; the caller still sees the rejection).
53
+ *
54
+ * The queue is bounded by {@link maxDepth}: once `depth` reaches the cap, `run`
55
+ * rejects synchronously with a {@link SerialQueueFullError} BEFORE incrementing
56
+ * or chaining, so an over-cap task never enters the chain (mirroring the harness
57
+ * LiveSessionManager's maxPendingPerConversation rejection).
58
+ */
59
+ export class SerialQueue {
60
+ tail = Promise.resolve();
61
+ depth = 0;
62
+ maxDepth;
63
+ constructor(maxDepth = DEFAULT_ADMISSION_QUEUE_MAX_DEPTH) {
64
+ this.maxDepth = maxDepth;
65
+ }
66
+ run(task) {
67
+ if (this.depth >= this.maxDepth) {
68
+ return Promise.reject(new SerialQueueFullError(this.maxDepth));
69
+ }
70
+ this.depth += 1;
71
+ const result = this.tail.then(() => task());
72
+ this.tail = result.then(() => undefined, () => undefined);
73
+ void result.then(() => { this.depth -= 1; }, () => { this.depth -= 1; });
74
+ return result;
75
+ }
76
+ /** True when no task is queued or running. */
77
+ get idle() {
78
+ return this.depth === 0;
79
+ }
80
+ }
81
+ function isSerialQueueFullError(error) {
82
+ return error instanceof SerialQueueFullError;
83
+ }
12
84
  export class SlackAdapter {
13
85
  api;
14
86
  responder;
@@ -20,8 +92,37 @@ export class SlackAdapter {
20
92
  stripMentionText;
21
93
  streamOptions;
22
94
  messages;
95
+ attachmentMaxBytes;
96
+ allowedMimeTypes;
97
+ /** callback_id → shortcut binding for registered Slack shortcuts. */
98
+ shortcuts;
99
+ /** action_id → button binding for App Home tab buttons. */
100
+ homeButtons;
101
+ /** Ordered Home tab buttons (for rendering the view in config order). */
102
+ homeButtonOrder;
103
+ homeTabEnabled;
104
+ homeTabHeaderText;
105
+ /** First allowlisted channel (original case), used as a global interaction's default reply destination. */
106
+ defaultShortcutChannelId;
23
107
  logger;
24
- activeRuns = new Map();
108
+ resolvePostIndex;
109
+ recordPostedMessage;
110
+ /**
111
+ * In-flight abort controllers per thread. The harness serializes runs for a
112
+ * conversation, so several may be queued/active concurrently; /cancel aborts
113
+ * every controller for the thread (and clears the harness queue via
114
+ * responder.cancel).
115
+ */
116
+ activeControllers = new Map();
117
+ /**
118
+ * Per-conversation admission queue. Socket Mode dispatches envelopes
119
+ * concurrently, and pre-submit work (status + file download) is variable
120
+ * latency, so without this a later same-thread message could reach
121
+ * responder.respond() (and the harness FIFO) before an earlier one. We
122
+ * serialize respondToEvent per conversation to preserve message order.
123
+ * /cancel stays out-of-band (handled before this queue).
124
+ */
125
+ admissionQueues = new Map();
25
126
  constructor(options) {
26
127
  this.api = options.api;
27
128
  this.responder = options.responder;
@@ -34,12 +135,29 @@ export class SlackAdapter {
34
135
  options.stripMentionText ?? (this.botUserIds.size > 0 || this.mentionTextAliases.length > 0);
35
136
  this.streamOptions = options.stream ?? {};
36
137
  this.messages = { ...DEFAULT_MESSAGES, ...options.messages };
138
+ this.attachmentMaxBytes = options.attachments?.maxBytes ?? DEFAULT_ATTACHMENT_MAX_BYTES;
139
+ this.allowedMimeTypes = new Set((options.attachments?.allowedMimeTypes ?? DEFAULT_ALLOWED_MIME_TYPES).map((mime) => mime.trim().toLowerCase()));
140
+ this.shortcuts = new Map((options.shortcuts ?? [])
141
+ .filter((binding) => binding.callbackId.trim().length > 0)
142
+ .map((binding) => [binding.callbackId, binding]));
143
+ this.homeButtonOrder = (options.homeTab?.buttons ?? []).filter((button) => button.actionId.trim().length > 0);
144
+ this.homeButtons = new Map(this.homeButtonOrder.map((button) => [button.actionId, button]));
145
+ this.homeTabEnabled = options.homeTab?.enabled === true;
146
+ this.homeTabHeaderText = options.homeTab?.headerText;
147
+ this.defaultShortcutChannelId = options.allowedChannelIds?.[0];
37
148
  this.logger = options.logger;
149
+ this.resolvePostIndex = options.resolvePostIndex;
150
+ this.recordPostedMessage = options.recordPostedMessage;
38
151
  if (!this.allowAllChannels && this.allowedChannelIds.size === 0) {
39
152
  throw new TypeError("SlackAdapter requires allowedChannelIds or allowAllChannels: true.");
40
153
  }
41
154
  }
42
155
  async handleEventCallback(callback) {
156
+ // App Home tab opened → (re)publish the button panel. This is an events_api
157
+ // event with no channel/trigger, so it is handled before the message path.
158
+ if (callback.event.type === "app_home_opened" && callback.event.tab === "home") {
159
+ return this.handleAppHomeOpened(callback);
160
+ }
43
161
  const normalized = this.normalizeEventCallback(callback);
44
162
  if (normalized.kind === "ignored") {
45
163
  return normalized;
@@ -58,7 +176,7 @@ export class SlackAdapter {
58
176
  };
59
177
  }
60
178
  const text = event.text.trim();
61
- if (text.length === 0) {
179
+ if (text.length === 0 && event.files.length === 0) {
62
180
  await this.api.chatPostMessage({
63
181
  channel: event.channelId,
64
182
  text: this.messages.unsupportedText,
@@ -103,10 +221,20 @@ export class SlackAdapter {
103
221
  };
104
222
  }
105
223
  const runKey = runKeyFor(event);
106
- const activeRun = this.activeRuns.get(runKey);
224
+ // Resolve once, up front, so /cancel, the admission queue, and the run all use
225
+ // the SAME conversationId — an in-thread reply to a message we posted resolves
226
+ // to the producing conversation (so it loads that history), else the default
227
+ // slack: thread id.
228
+ const conversationId = await this.resolveConversationId(event);
107
229
  if (command?.name === "cancel") {
108
- if (activeRun !== undefined) {
109
- activeRun.controller.abort(new Error("Cancelled by Slack user."));
230
+ // Clear any queued follow-ups for the conversation (the harness owns the
231
+ // queue) and abort every in-flight controller for this thread.
232
+ this.responder.cancel?.(conversationId, new Error("Cancelled by Slack user."));
233
+ const controllers = this.activeControllers.get(runKey);
234
+ if (controllers !== undefined) {
235
+ for (const controller of controllers) {
236
+ controller.abort(new Error("Cancelled by Slack user."));
237
+ }
110
238
  }
111
239
  await this.api.chatPostMessage({
112
240
  channel: event.channelId,
@@ -119,28 +247,426 @@ export class SlackAdapter {
119
247
  channelId: event.channelId,
120
248
  };
121
249
  }
122
- if (activeRun !== undefined) {
123
- await this.api.chatPostMessage({
124
- channel: event.channelId,
125
- text: this.messages.busyText,
126
- thread_ts: event.threadTs,
250
+ // No per-thread "busy" rejection: the harness serializes runs for the
251
+ // conversation, queuing a concurrent message and answering it on the warm
252
+ // session after the current turn. We admit messages through a per-conversation
253
+ // serial queue first so they reach the harness in arrival order even when an
254
+ // earlier message stalls on file download.
255
+ //
256
+ // Create and register the controller BEFORE entering the admission queue so a
257
+ // /cancel can abort a message still parked behind an earlier same-thread run.
258
+ // respondToEvent's first abort check then makes the queued-then-cancelled run
259
+ // bail before responder.respond and post only the cancelled terminal.
260
+ const controller = new AbortController();
261
+ this.registerController(runKey, controller);
262
+ let queue = this.admissionQueues.get(conversationId);
263
+ if (queue === undefined) {
264
+ queue = new SerialQueue();
265
+ this.admissionQueues.set(conversationId, queue);
266
+ }
267
+ try {
268
+ return await queue.run(() => this.respondToEvent(event, text, runKey, controller, conversationId));
269
+ }
270
+ catch (error) {
271
+ if (isSerialQueueFullError(error)) {
272
+ // Over-cap: the task was rejected BEFORE entering the queue, so
273
+ // respondToEvent (and its finally) never ran. Unregister the eagerly
274
+ // created controller here so it does not leak in activeControllers, then
275
+ // answer with the busy terminal instead of admitting an unbounded backlog.
276
+ this.unregisterController(runKey, controller);
277
+ await this.api.chatPostMessage({
278
+ channel: event.channelId,
279
+ text: this.messages.busyText,
280
+ thread_ts: event.threadTs,
281
+ });
282
+ return { kind: "busy", eventId: event.eventId, channelId: event.channelId };
283
+ }
284
+ throw error;
285
+ }
286
+ finally {
287
+ if (queue.idle && this.admissionQueues.get(conversationId) === queue) {
288
+ this.admissionQueues.delete(conversationId);
289
+ }
290
+ }
291
+ }
292
+ /**
293
+ * Deliver a proactive notification to a Slack destination by running it as a
294
+ * turn on that destination's OWN harness (shared session/history + the same
295
+ * per-conversation admission queue as inbound messages) and posting the answer
296
+ * through the normal stream. `threadTs` targets an existing thread (clean
297
+ * continuity — the user's in-thread replies share the session); omitting it
298
+ * posts top-level (fire-and-forget: a fresh top-level post has no pre-existing
299
+ * thread to share continuity with). Used by cron/webhook nudges. Best-effort:
300
+ * a failed or empty turn posts nothing.
301
+ */
302
+ async notify(channelId, threadTs, text, options) {
303
+ const conversationId = threadTs === undefined ? `slack:${channelId}` : `slack:${channelId}:${threadTs}`;
304
+ // A threaded proactive run shares the inbound /cancel key so a user's
305
+ // /cancel in that thread can abort it; a top-level post (no thread) has no
306
+ // inbound /cancel target, so it keeps its own proactive key.
307
+ const runKey = threadTs === undefined ? `proactive:${conversationId}` : `${channelId}:${threadTs}`;
308
+ const controller = new AbortController();
309
+ this.registerController(runKey, controller);
310
+ let queue = this.admissionQueues.get(conversationId);
311
+ if (queue === undefined) {
312
+ queue = new SerialQueue();
313
+ this.admissionQueues.set(conversationId, queue);
314
+ }
315
+ try {
316
+ return await queue.run(() => options?.verbatim === true
317
+ ? this.runVerbatimDelivery(conversationId, channelId, threadTs, text, runKey, controller)
318
+ : this.runProactiveTurn(conversationId, channelId, threadTs, text, runKey, controller));
319
+ }
320
+ catch (error) {
321
+ if (isSerialQueueFullError(error)) {
322
+ this.unregisterController(runKey, controller);
323
+ this.logger?.warn?.("Slack proactive notify dropped: conversation is at its concurrency cap.", {
324
+ conversationId,
325
+ });
326
+ return { delivered: false, reason: "conversation at concurrency cap" };
327
+ }
328
+ throw error;
329
+ }
330
+ finally {
331
+ if (queue.idle && this.admissionQueues.get(conversationId) === queue) {
332
+ this.admissionQueues.delete(conversationId);
333
+ }
334
+ }
335
+ }
336
+ /**
337
+ * Route any interactivity payload to the right handler: a shortcut/message
338
+ * action by `callback_id`, or a Block Kit button click by `action_id`.
339
+ */
340
+ async handleInteraction(payload) {
341
+ if (payload.type === "block_actions") {
342
+ return this.handleBlockActions(payload);
343
+ }
344
+ return this.handleShortcut(payload);
345
+ }
346
+ /**
347
+ * Route a Slack shortcut payload. When its `callback_id` is bound to a prompt
348
+ * and the resolved destination channel is authorized, run that prompt as a
349
+ * proactive turn. The destination is the binding's `channelId`, else the
350
+ * payload's own channel (message shortcuts), else the first allowlisted channel.
351
+ */
352
+ async handleShortcut(payload) {
353
+ const callbackId = typeof payload.callback_id === "string" ? payload.callback_id : undefined;
354
+ if (callbackId === undefined || callbackId.length === 0) {
355
+ return { kind: "ignored", reason: "no_action" };
356
+ }
357
+ const binding = this.shortcuts.get(callbackId);
358
+ if (binding === undefined) {
359
+ return { kind: "ignored", reason: "unbound", id: callbackId };
360
+ }
361
+ // A message shortcut can reply in the source thread; a global shortcut has no
362
+ // thread, so the run posts top-level in the destination channel.
363
+ const threadTs = firstNonEmpty(payload.message?.thread_ts, payload.message?.ts);
364
+ return this.runBoundInteraction(callbackId, binding, payload.channel?.id, threadTs);
365
+ }
366
+ /**
367
+ * Route a Block Kit `block_actions` payload (a clicked button — typically on the
368
+ * App Home tab). Acts on the first action whose `action_id` is a bound Home
369
+ * button. A Home-tab click carries no channel, so the reply goes to the button's
370
+ * `channelId` (or the first allowlisted channel).
371
+ */
372
+ async handleBlockActions(payload) {
373
+ const actions = Array.isArray(payload.actions) ? payload.actions : [];
374
+ if (actions.length === 0) {
375
+ return { kind: "ignored", reason: "no_action" };
376
+ }
377
+ const action = actions.find((candidate) => typeof candidate.action_id === "string" && this.homeButtons.has(candidate.action_id));
378
+ if (action === undefined || typeof action.action_id !== "string") {
379
+ return { kind: "ignored", reason: "unbound" };
380
+ }
381
+ const actionId = action.action_id;
382
+ const binding = this.homeButtons.get(actionId);
383
+ if (binding === undefined) {
384
+ return { kind: "ignored", reason: "unbound", id: actionId };
385
+ }
386
+ const threadTs = firstNonEmpty(payload.message?.thread_ts, payload.message?.ts);
387
+ return this.runBoundInteraction(actionId, binding, payload.channel?.id, threadTs);
388
+ }
389
+ /**
390
+ * Shared interaction run path: resolve the destination channel, enforce the
391
+ * allowlist, post the optional instant ack, then run the bound prompt as a
392
+ * proactive turn. The returned result is for logging.
393
+ */
394
+ async runBoundInteraction(id, binding, payloadChannelId, payloadThreadTs) {
395
+ const channelId = firstNonEmpty(binding.channelId, payloadChannelId, this.defaultShortcutChannelId);
396
+ if (channelId === undefined) {
397
+ this.logger?.warn?.("Slack interaction has no destination channel; set channelId on the binding.", { id });
398
+ return { kind: "ignored", reason: "missing_channel", id };
399
+ }
400
+ if (!this.isAuthorized(channelId)) {
401
+ this.logger?.warn?.("Slack interaction targets an unauthorized channel; ignored.", { id, channelId });
402
+ return { kind: "unauthorized", id, channelId };
403
+ }
404
+ // A Slack `thread_ts` is channel-scoped, so only thread the reply when the
405
+ // destination IS the channel the interaction came from. A binding that pins a
406
+ // different channelId (or a global shortcut / Home-tab click with no source
407
+ // channel) posts top-level — otherwise a foreign thread_ts 404s the post.
408
+ const threadTs = channelId === payloadChannelId ? payloadThreadTs : undefined;
409
+ // Instant feedback: the result lands seconds later (and a global shortcut or a
410
+ // Home-tab click shows no on-click UI), so post the ack now (best-effort)
411
+ // before the run. The result follows as its own message in the same channel.
412
+ if (binding.ackText !== undefined) {
413
+ try {
414
+ await this.api.chatPostMessage({
415
+ channel: channelId,
416
+ text: binding.ackText,
417
+ ...(threadTs === undefined ? {} : { thread_ts: threadTs }),
418
+ });
419
+ }
420
+ catch (error) {
421
+ this.logger?.warn?.("Slack interaction ack message failed (continuing with the run).", {
422
+ id,
423
+ error: error instanceof Error ? error.message : String(error),
424
+ });
425
+ }
426
+ }
427
+ const delivery = await this.notify(channelId, threadTs, binding.prompt);
428
+ const result = {
429
+ kind: "triggered",
430
+ id,
431
+ channelId,
432
+ delivered: delivery.delivered,
433
+ };
434
+ if (delivery.reason !== undefined) {
435
+ result.reason = delivery.reason;
436
+ }
437
+ return result;
438
+ }
439
+ /**
440
+ * Publish the App Home tab for a user when they open it. Best-effort: a publish
441
+ * failure is logged, not thrown, so opening Home never surfaces an error.
442
+ */
443
+ async handleAppHomeOpened(callback) {
444
+ const userId = callback.event.user;
445
+ if (!this.homeTabEnabled || typeof userId !== "string" || userId.length === 0) {
446
+ return { kind: "ignored", reason: "unsupported_event", eventId: callback.event_id };
447
+ }
448
+ if (typeof this.api.viewsPublish !== "function") {
449
+ this.logger?.warn?.("Home tab is enabled but the Slack client cannot publish views.");
450
+ return { kind: "ignored", reason: "unsupported_event", eventId: callback.event_id };
451
+ }
452
+ const blocks = this.buildHomeTabBlocks();
453
+ if (blocks.length === 0) {
454
+ // views.publish requires 1–100 blocks; an enabled-but-empty Home tab would
455
+ // error on every open. Skip the publish instead (config load also rejects
456
+ // this combination, so it should not happen in practice).
457
+ this.logger?.warn?.("Skipping App Home publish: the Home view has no blocks.");
458
+ return { kind: "ignored", reason: "unsupported_event", eventId: callback.event_id };
459
+ }
460
+ try {
461
+ await this.api.viewsPublish({ userId, view: { type: "home", blocks } });
462
+ return { kind: "home_published", eventId: callback.event_id, userId };
463
+ }
464
+ catch (error) {
465
+ this.logger?.error?.("Failed to publish the Slack App Home tab.", {
466
+ userId,
467
+ error: error instanceof Error ? error.message : String(error),
127
468
  });
128
- return {
129
- kind: "busy",
130
- eventId: event.eventId,
131
- channelId: event.channelId,
469
+ return { kind: "error", eventId: callback.event_id, error };
470
+ }
471
+ }
472
+ /** Build the App Home tab Block Kit: an optional header plus one button per configured Home button. */
473
+ buildHomeTabBlocks() {
474
+ const blocks = [];
475
+ if (this.homeTabHeaderText !== undefined && this.homeTabHeaderText.trim().length > 0) {
476
+ blocks.push({ type: "section", text: { type: "mrkdwn", text: this.homeTabHeaderText } });
477
+ }
478
+ const elements = this.homeButtonOrder.map((button) => ({
479
+ type: "button",
480
+ text: { type: "plain_text", text: button.label, emoji: true },
481
+ action_id: button.actionId,
482
+ value: button.actionId,
483
+ }));
484
+ if (elements.length > 0) {
485
+ blocks.push({ type: "actions", block_id: "home_actions", elements });
486
+ }
487
+ return blocks;
488
+ }
489
+ /**
490
+ * Build the stream options shared by both proactive delivery paths
491
+ * ({@link runProactiveTurn} and {@link runVerbatimDelivery}): a threaded post
492
+ * targets the existing thread; a top-level post records its ts → this
493
+ * conversation so a user's in-thread reply resolves back here.
494
+ */
495
+ buildProactiveStreamOptions(conversationId, channelId, threadTs, controller) {
496
+ const streamOptions = {
497
+ api: this.api,
498
+ channelId,
499
+ // No reactToTs: a proactive turn has no inbound message to react to.
500
+ finalOnly: this.streamOptions.finalOnly ?? true,
501
+ abortSignal: controller.signal,
502
+ };
503
+ if (threadTs !== undefined) {
504
+ streamOptions.threadTs = threadTs;
505
+ }
506
+ else if (this.recordPostedMessage !== undefined) {
507
+ // A top-level proactive post is a fresh thread root with no prior history.
508
+ // Record its ts → this conversation so a user's in-thread reply resolves
509
+ // back here (the threaded case already shares the thread's conversationId).
510
+ const record = this.recordPostedMessage;
511
+ let recorded = false;
512
+ streamOptions.onPosted = ({ ts, channel }) => {
513
+ if (recorded || ts.length === 0) {
514
+ return;
515
+ }
516
+ recorded = true;
517
+ record(channel, ts, conversationId);
132
518
  };
133
519
  }
134
- return await this.respondToEvent(event, text, runKey);
520
+ if (this.streamOptions.maxMessageChars !== undefined) {
521
+ streamOptions.maxMessageChars = this.streamOptions.maxMessageChars;
522
+ }
523
+ if (this.streamOptions.maxSendRetries !== undefined) {
524
+ streamOptions.maxSendRetries = this.streamOptions.maxSendRetries;
525
+ }
526
+ if (this.streamOptions.retryCapMs !== undefined) {
527
+ streamOptions.retryCapMs = this.streamOptions.retryCapMs;
528
+ }
529
+ if (this.streamOptions.retryBaseDelayMs !== undefined) {
530
+ streamOptions.retryBaseDelayMs = this.streamOptions.retryBaseDelayMs;
531
+ }
532
+ if (this.logger !== undefined) {
533
+ streamOptions.logger = this.logger;
534
+ }
535
+ return streamOptions;
135
536
  }
136
- async respondToEvent(event, text, runKey) {
137
- const controller = new AbortController();
138
- const activeRun = { controller };
139
- this.activeRuns.set(runKey, activeRun);
537
+ /**
538
+ * Deliver `text` VERBATIM to a Slack destination: post it unchanged through the
539
+ * normal stream with NO model call (the producing cron/webhook run already wrote
540
+ * the message), then record it to the destination's durable history via the
541
+ * responder so a later reply resumes with it in context. Best-effort: a
542
+ * history-record failure never fails an already-delivered post.
543
+ */
544
+ async runVerbatimDelivery(conversationId, channelId, threadTs, text, runKey, controller) {
545
+ const stream = new SlackMessageStream(this.buildProactiveStreamOptions(conversationId, channelId, threadTs, controller));
546
+ try {
547
+ if (controller.signal.aborted) {
548
+ return { delivered: false, reason: "cancelled" };
549
+ }
550
+ if (text.trim().length === 0) {
551
+ return { delivered: false, reason: "empty notification" };
552
+ }
553
+ try {
554
+ await stream.finish(text);
555
+ }
556
+ catch (error) {
557
+ if (controller.signal.aborted || isAgentResponseCancelledError(error)) {
558
+ return { delivered: false, reason: "cancelled" };
559
+ }
560
+ this.logger?.error?.("Slack verbatim notify delivery failed.", {
561
+ error: error instanceof Error ? error.message : String(error),
562
+ });
563
+ return { delivered: false, reason: "delivery failed" };
564
+ }
565
+ try {
566
+ await this.responder.deliverVerbatim?.(conversationId, text);
567
+ }
568
+ catch (error) {
569
+ this.logger?.warn?.("Slack verbatim notify history record failed.", {
570
+ error: error instanceof Error ? error.message : String(error),
571
+ });
572
+ }
573
+ return { delivered: true };
574
+ }
575
+ finally {
576
+ this.unregisterController(runKey, controller);
577
+ }
578
+ }
579
+ async runProactiveTurn(conversationId, channelId, threadTs, text, runKey, controller) {
580
+ const stream = new SlackMessageStream(this.buildProactiveStreamOptions(conversationId, channelId, threadTs, controller));
581
+ try {
582
+ if (controller.signal.aborted) {
583
+ return { delivered: false, reason: "cancelled" };
584
+ }
585
+ const request = {
586
+ conversationId,
587
+ channelId,
588
+ messageTs: threadTs ?? "",
589
+ threadTs: threadTs ?? "",
590
+ eventId: "proactive",
591
+ text,
592
+ trigger: "direct",
593
+ abortSignal: controller.signal,
594
+ metadata: {
595
+ slack: {
596
+ eventId: "proactive",
597
+ channel: { id: channelId },
598
+ message: { ts: threadTs ?? "" },
599
+ trigger: "direct",
600
+ },
601
+ },
602
+ };
603
+ let response;
604
+ try {
605
+ response = await this.responder.respond(request, stream);
606
+ }
607
+ catch (error) {
608
+ if (controller.signal.aborted || isAgentResponseCancelledError(error)) {
609
+ return { delivered: false, reason: "cancelled" };
610
+ }
611
+ this.logger?.error?.("Slack proactive notify failed.", {
612
+ error: error instanceof Error ? error.message : String(error),
613
+ });
614
+ return { delivered: false, reason: "responder failed" };
615
+ }
616
+ const answer = response.text;
617
+ if (controller.signal.aborted) {
618
+ return { delivered: false, reason: "cancelled" };
619
+ }
620
+ if (answer === undefined || answer.trim().length === 0) {
621
+ return { delivered: false, reason: "agent produced no answer" };
622
+ }
623
+ try {
624
+ await stream.finish(answer);
625
+ }
626
+ catch (error) {
627
+ if (controller.signal.aborted || isAgentResponseCancelledError(error)) {
628
+ return { delivered: false, reason: "cancelled" };
629
+ }
630
+ this.logger?.error?.("Slack proactive delivery failed after a successful AI run.", {
631
+ error: error instanceof Error ? error.message : String(error),
632
+ });
633
+ return { delivered: false, reason: "delivery failed" };
634
+ }
635
+ return { delivered: true };
636
+ }
637
+ finally {
638
+ this.unregisterController(runKey, controller);
639
+ }
640
+ }
641
+ /**
642
+ * The conversation the run should continue. A genuine in-thread reply
643
+ * (`threadTs !== messageTs`) whose `(channel, threadTs)` matches a message we
644
+ * posted resolves to that producing conversation; everything else uses the
645
+ * default `slack:<channel>:<threadTs>`. Best-effort: a lookup error falls back.
646
+ */
647
+ async resolveConversationId(event) {
648
+ const fallback = conversationIdFor(event);
649
+ if (this.resolvePostIndex === undefined || event.threadTs === event.messageTs) {
650
+ return fallback;
651
+ }
652
+ try {
653
+ const producing = await this.resolvePostIndex(event.channelId, event.threadTs);
654
+ return producing !== undefined && producing.length > 0 ? producing : fallback;
655
+ }
656
+ catch {
657
+ return fallback;
658
+ }
659
+ }
660
+ async respondToEvent(event, text, runKey, controller, conversationId) {
140
661
  const streamOptions = {
141
662
  api: this.api,
142
663
  channelId: event.channelId,
143
664
  threadTs: event.threadTs,
665
+ reactToTs: event.messageTs,
666
+ // Default to a 👀 "seen" reaction + final-answer-only delivery (no streamed
667
+ // interim edits); a tuning override can restore interim streaming.
668
+ finalOnly: this.streamOptions.finalOnly ?? true,
669
+ abortSignal: controller.signal,
144
670
  };
145
671
  if (this.streamOptions.initialStatusText !== undefined) {
146
672
  streamOptions.initialStatusText = this.streamOptions.initialStatusText;
@@ -151,6 +677,18 @@ export class SlackAdapter {
151
677
  if (this.streamOptions.maxMessageChars !== undefined) {
152
678
  streamOptions.maxMessageChars = this.streamOptions.maxMessageChars;
153
679
  }
680
+ if (this.streamOptions.maxSendRetries !== undefined) {
681
+ streamOptions.maxSendRetries = this.streamOptions.maxSendRetries;
682
+ }
683
+ if (this.streamOptions.retryCapMs !== undefined) {
684
+ streamOptions.retryCapMs = this.streamOptions.retryCapMs;
685
+ }
686
+ if (this.streamOptions.retryBaseDelayMs !== undefined) {
687
+ streamOptions.retryBaseDelayMs = this.streamOptions.retryBaseDelayMs;
688
+ }
689
+ if (this.streamOptions.showHints !== undefined) {
690
+ streamOptions.showHints = this.streamOptions.showHints;
691
+ }
154
692
  if (this.logger !== undefined) {
155
693
  streamOptions.logger = this.logger;
156
694
  }
@@ -161,7 +699,20 @@ export class SlackAdapter {
161
699
  await stream.finish(this.messages.cancelledText);
162
700
  return { kind: "cancelled", eventId: event.eventId, channelId: event.channelId };
163
701
  }
164
- const request = buildAgentRequest(event, text, controller.signal);
702
+ const attachments = await this.downloadAttachments(event.files, controller.signal);
703
+ if (controller.signal.aborted) {
704
+ await stream.finish(this.messages.cancelledText);
705
+ return { kind: "cancelled", eventId: event.eventId, channelId: event.channelId };
706
+ }
707
+ // A file-only message whose files were all skipped (MIME/size/missing
708
+ // URL/download failure) leaves no text and no attachments. Deliver a
709
+ // deterministic message instead of submitting an empty request the harness
710
+ // would reject.
711
+ if (text.length === 0 && attachments.length === 0) {
712
+ await stream.finish(this.messages.unsupportedText);
713
+ return { kind: "ignored", reason: "no_usable_attachments", eventId: event.eventId, channelId: event.channelId };
714
+ }
715
+ const request = buildAgentRequest(event, text, controller.signal, attachments, conversationId);
165
716
  const response = await this.responder.respond(request, stream);
166
717
  if (controller.signal.aborted) {
167
718
  await stream.finish(this.messages.cancelledText);
@@ -192,9 +743,89 @@ export class SlackAdapter {
192
743
  return { kind: "error", eventId: event.eventId, channelId: event.channelId, error };
193
744
  }
194
745
  finally {
195
- if (this.activeRuns.get(runKey) === activeRun) {
196
- this.activeRuns.delete(runKey);
746
+ this.unregisterController(runKey, controller);
747
+ }
748
+ }
749
+ /**
750
+ * Download each inbound Slack file's bytes into an {@link AgentAttachment}.
751
+ * Files with a missing/disallowed mimetype, no private URL, or an advertised
752
+ * size over the cap are skipped before any network call. The byte cap is also
753
+ * enforced during the download. A failed download skips that file and
754
+ * continues; downloads are tied to the request abort signal.
755
+ */
756
+ async downloadAttachments(files, signal) {
757
+ const attachments = [];
758
+ // downloadFile is optional on SlackWebApi: a text-only custom client may omit
759
+ // it. Without it we cannot fetch bytes, so skip attachment download entirely.
760
+ if (typeof this.api.downloadFile !== "function") {
761
+ if (files.length > 0) {
762
+ this.logger?.debug?.("Slack client has no downloadFile; skipping attachments.", { count: files.length });
763
+ }
764
+ return attachments;
765
+ }
766
+ const downloadFile = this.api.downloadFile.bind(this.api);
767
+ for (const file of files) {
768
+ if (signal.aborted) {
769
+ break;
770
+ }
771
+ const mimeType = typeof file.mimetype === "string" ? file.mimetype : "";
772
+ if (mimeType.length === 0 || !this.allowedMimeTypes.has(mimeType.toLowerCase())) {
773
+ this.logger?.debug?.("Skipped Slack file with disallowed mimetype.", {
774
+ id: file.id,
775
+ mimeType,
776
+ });
777
+ continue;
778
+ }
779
+ if (typeof file.size === "number" && file.size > this.attachmentMaxBytes) {
780
+ this.logger?.debug?.("Skipped Slack file exceeding the byte cap.", {
781
+ id: file.id,
782
+ size: file.size,
783
+ });
784
+ continue;
785
+ }
786
+ const url = file.url_private ?? file.url_private_download;
787
+ if (typeof url !== "string" || url.length === 0) {
788
+ this.logger?.debug?.("Skipped Slack file without a private URL.", { id: file.id });
789
+ continue;
790
+ }
791
+ let bytes;
792
+ try {
793
+ bytes = await downloadFile({ url, maxBytes: this.attachmentMaxBytes }, { signal });
197
794
  }
795
+ catch (error) {
796
+ this.logger?.warn?.("Failed to download a Slack file (skipped).", {
797
+ id: file.id,
798
+ error: error instanceof Error ? error.message : String(error),
799
+ });
800
+ continue;
801
+ }
802
+ if (bytes.byteLength > this.attachmentMaxBytes) {
803
+ this.logger?.debug?.("Skipped Slack file exceeding the byte cap.", {
804
+ id: file.id,
805
+ size: bytes.byteLength,
806
+ });
807
+ continue;
808
+ }
809
+ attachments.push(buildAttachment(file, mimeType, bytes));
810
+ }
811
+ return attachments;
812
+ }
813
+ registerController(runKey, controller) {
814
+ let controllers = this.activeControllers.get(runKey);
815
+ if (controllers === undefined) {
816
+ controllers = new Set();
817
+ this.activeControllers.set(runKey, controllers);
818
+ }
819
+ controllers.add(controller);
820
+ }
821
+ unregisterController(runKey, controller) {
822
+ const controllers = this.activeControllers.get(runKey);
823
+ if (controllers === undefined) {
824
+ return;
825
+ }
826
+ controllers.delete(controller);
827
+ if (controllers.size === 0) {
828
+ this.activeControllers.delete(runKey);
198
829
  }
199
830
  }
200
831
  normalizeEventCallback(callback) {
@@ -210,16 +841,19 @@ export class SlackAdapter {
210
841
  if (rawEvent.user !== undefined && this.botUserIds.has(normalizeIdForMatch(rawEvent.user))) {
211
842
  return ignored("from_self", callback, rawEvent);
212
843
  }
213
- if (rawEvent.subtype !== undefined) {
844
+ if (rawEvent.subtype !== undefined && !FILE_BEARING_MESSAGE_SUBTYPES.has(rawEvent.subtype)) {
214
845
  return ignored("unsupported_message", callback, rawEvent);
215
846
  }
216
847
  if (eventType === "message" &&
217
848
  rawEvent.channel_type !== "im") {
218
849
  return ignored("unsupported_event", callback, rawEvent);
219
850
  }
851
+ // A file upload may arrive with no caption (text absent/empty); accept it as
852
+ // long as it carries files so the attachment is not dropped.
853
+ const hasFiles = Array.isArray(rawEvent.files) && rawEvent.files.length > 0;
220
854
  if (typeof rawEvent.channel !== "string" ||
221
855
  typeof rawEvent.ts !== "string" ||
222
- typeof rawEvent.text !== "string") {
856
+ (typeof rawEvent.text !== "string" && !hasFiles)) {
223
857
  return ignored("unsupported_message", callback, rawEvent);
224
858
  }
225
859
  const threadTs = typeof rawEvent.thread_ts === "string" && rawEvent.thread_ts.trim().length > 0
@@ -228,10 +862,11 @@ export class SlackAdapter {
228
862
  const event = {
229
863
  eventId: callback.event_id,
230
864
  channelId: rawEvent.channel,
231
- text: this.prepareText(rawEvent.text),
865
+ text: this.prepareText(typeof rawEvent.text === "string" ? rawEvent.text : ""),
232
866
  messageTs: rawEvent.ts,
233
867
  threadTs,
234
868
  trigger,
869
+ files: Array.isArray(rawEvent.files) ? rawEvent.files : [],
235
870
  };
236
871
  if (callback.team_id !== undefined) {
237
872
  event.teamId = callback.team_id;
@@ -293,7 +928,7 @@ function ignored(reason, callback, event) {
293
928
  }
294
929
  return result;
295
930
  }
296
- function buildAgentRequest(event, text, abortSignal) {
931
+ function buildAgentRequest(event, text, abortSignal, attachments, conversationId) {
297
932
  const metadata = {
298
933
  eventId: event.eventId,
299
934
  channel: {
@@ -326,7 +961,7 @@ function buildAgentRequest(event, text, abortSignal) {
326
961
  metadata.user = { id: event.userId };
327
962
  }
328
963
  const request = {
329
- conversationId: `slack:${event.channelId}:${event.threadTs}`,
964
+ conversationId,
330
965
  channelId: event.channelId,
331
966
  messageTs: event.messageTs,
332
967
  threadTs: event.threadTs,
@@ -342,8 +977,36 @@ function buildAgentRequest(event, text, abortSignal) {
342
977
  if (event.userId !== undefined) {
343
978
  request.userId = event.userId;
344
979
  }
980
+ if (attachments.length > 0) {
981
+ request.attachments = attachments;
982
+ }
345
983
  return request;
346
984
  }
985
+ const TEXT_MIME_PATTERN = /^text\/|[/+](?:json|xml|csv)$/iu;
986
+ /** Build an {@link AgentAttachment} from downloaded Slack file bytes. */
987
+ function buildAttachment(file, mimeType, bytes) {
988
+ const attachment = {
989
+ kind: mimeType.toLowerCase().startsWith("image/") ? "image" : "document",
990
+ mimeType,
991
+ data: toBase64(bytes),
992
+ sizeBytes: bytes.byteLength,
993
+ };
994
+ const name = typeof file.name === "string" && file.name.length > 0
995
+ ? file.name
996
+ : typeof file.title === "string" && file.title.length > 0
997
+ ? file.title
998
+ : undefined;
999
+ if (name !== undefined) {
1000
+ attachment.name = name;
1001
+ }
1002
+ if (TEXT_MIME_PATTERN.test(mimeType)) {
1003
+ attachment.text = new TextDecoder("utf-8").decode(bytes);
1004
+ }
1005
+ return attachment;
1006
+ }
1007
+ function toBase64(bytes) {
1008
+ return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString("base64");
1009
+ }
347
1010
  function parseCommand(text) {
348
1011
  const match = text.match(/^\/([A-Za-z0-9_]+)(?:\s|$)/u);
349
1012
  if (match?.[1] === undefined) {
@@ -364,7 +1027,23 @@ async function finishSafely(stream, text, logger) {
364
1027
  function runKeyFor(event) {
365
1028
  return `${event.channelId}:${event.threadTs}`;
366
1029
  }
1030
+ /**
1031
+ * The conversation key the harness serializes runs on. /cancel uses the same id
1032
+ * so the responder clears the right conversation's queued follow-ups.
1033
+ */
1034
+ function conversationIdFor(event) {
1035
+ return `slack:${event.channelId}:${event.threadTs}`;
1036
+ }
367
1037
  function normalizeIdForMatch(value) {
368
1038
  return value.trim().toLowerCase();
369
1039
  }
1040
+ /** First argument that is a non-blank string, else undefined. */
1041
+ function firstNonEmpty(...values) {
1042
+ for (const value of values) {
1043
+ if (typeof value === "string" && value.trim().length > 0) {
1044
+ return value;
1045
+ }
1046
+ }
1047
+ return undefined;
1048
+ }
370
1049
  //# sourceMappingURL=adapter.js.map