@mono-agent/slack-adapter 0.2.2 β†’ 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -20,6 +20,17 @@ Adapter settings can be loaded from nested JSON under `slack` or explicit enviro
20
20
 
21
21
  The adapter is opt-in: `slack.enabled` / `MONO_AGENT_SLACK_ENABLED` defaults to `false`. While disabled the loader skips token validation and the channel reports `disabled` rather than `waiting_for_config`. Set `enabled: true` to turn it on; missing tokens or allowlist then surface as a real `waiting_for_config` reason.
22
22
 
23
+ ## Activity indicator (assistant status / πŸ‘€)
24
+
25
+ While the agent works, the message stream surfaces progress in the thread. It
26
+ prefers Slack's official assistant-thread status β€” `assistant.threads.setStatus`
27
+ ("App is _thinking…_"), which Slack auto-clears when the next message posts β€” and
28
+ falls back to a πŸ‘€ "seen" reaction on the triggering message. The status path only
29
+ applies inside a Slack **AI-assistant thread** and requires the app to have the
30
+ **Agents & AI Apps** feature enabled plus the **`assistant:write`** scope; in
31
+ regular channels/DMs (or without the scope) the call errors and the adapter uses
32
+ the reaction instead β€” no configuration needed for the fallback.
33
+
23
34
  ## Public API
24
35
 
25
36
  - `slackFieldGroup`
package/dist/adapter.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type AgentRequestBase, type AgentResponder as SharedAgentResponder, type AgentResponse } from "@mono-agent/agent-contracts";
1
+ import { type AgentAttachment, type AgentRequestBase, type AgentResponder as SharedAgentResponder, type AgentResponse } from "@mono-agent/agent-contracts";
2
2
  import { type AgentMessageStream, type SlackMessageStreamLogger } from "./message-stream.js";
3
3
  import type { SlackChannelId, SlackEventCallback, SlackMessageTs, SlackUserId, SlackWebApi } from "./types.js";
4
4
  export type SlackTriggerKind = "direct" | "app_mention";
@@ -13,11 +13,25 @@ export interface AgentRequest extends AgentRequestBase {
13
13
  text: string;
14
14
  trigger: SlackTriggerKind;
15
15
  abortSignal: AbortSignal;
16
+ attachments?: readonly AgentAttachment[];
16
17
  metadata: {
17
18
  slack: SlackRequestMetadata;
18
19
  [key: string]: unknown;
19
20
  };
20
21
  }
22
+ /** Tunes how inbound Slack file attachments are downloaded. */
23
+ export interface SlackAttachmentOptions {
24
+ /**
25
+ * Maximum decoded bytes to accept per file. Files whose advertised size
26
+ * exceeds this β€” or whose download exceeds it β€” are skipped. Default 10 MiB.
27
+ */
28
+ maxBytes?: number;
29
+ /**
30
+ * Allowed file mimetypes. A file whose mimetype is not listed is skipped.
31
+ * Defaults to a conservative allowlist of common image and document types.
32
+ */
33
+ allowedMimeTypes?: readonly string[];
34
+ }
21
35
  export interface SlackRequestMetadata {
22
36
  teamId?: string;
23
37
  apiAppId?: string;
@@ -52,6 +66,15 @@ export interface SlackAdapterStreamOptions {
52
66
  initialStatusText?: string;
53
67
  editDebounceMs?: number;
54
68
  maxMessageChars?: number;
69
+ maxSendRetries?: number;
70
+ retryCapMs?: number;
71
+ retryBaseDelayMs?: number;
72
+ showHints?: boolean;
73
+ /**
74
+ * Deliver only the final answer with a πŸ‘€ "seen" reaction while working,
75
+ * instead of streaming interim edits. Defaults to true for the Slack adapter.
76
+ */
77
+ finalOnly?: boolean;
55
78
  }
56
79
  export interface SlackAdapterLogger extends SlackMessageStreamLogger {
57
80
  debug?(message: string, metadata?: Record<string, unknown>): void;
@@ -67,9 +90,10 @@ export interface SlackAdapterOptions {
67
90
  stripMentionText?: boolean;
68
91
  stream?: SlackAdapterStreamOptions;
69
92
  messages?: SlackAdapterMessages;
93
+ attachments?: SlackAttachmentOptions;
70
94
  logger?: SlackAdapterLogger;
71
95
  }
72
- export type SlackEventIgnoredReason = "unsupported_event" | "unsupported_message" | "empty_text" | "from_bot" | "from_self";
96
+ export type SlackEventIgnoredReason = "unsupported_event" | "unsupported_message" | "empty_text" | "no_usable_attachments" | "from_bot" | "from_self";
73
97
  export type SlackEventHandlingResult = {
74
98
  kind: "handled";
75
99
  eventId: string;
@@ -101,6 +125,34 @@ export type SlackEventHandlingResult = {
101
125
  channelId?: SlackChannelId;
102
126
  error: unknown;
103
127
  };
128
+ /**
129
+ * Thrown synchronously by {@link SerialQueue.run} when the queue is already at
130
+ * its depth cap. The adapter catches this sentinel to answer with the busy
131
+ * terminal instead of admitting an unbounded backlog.
132
+ */
133
+ export declare class SerialQueueFullError extends Error {
134
+ readonly code: "serial_queue_full";
135
+ constructor(maxDepth: number);
136
+ }
137
+ /**
138
+ * Minimal per-conversation serial queue: each submitted task runs only after the
139
+ * previous one settles, preserving arrival order. A task's failure does not
140
+ * poison the queue (the chain swallows it; the caller still sees the rejection).
141
+ *
142
+ * The queue is bounded by {@link maxDepth}: once `depth` reaches the cap, `run`
143
+ * rejects synchronously with a {@link SerialQueueFullError} BEFORE incrementing
144
+ * or chaining, so an over-cap task never enters the chain (mirroring the harness
145
+ * LiveSessionManager's maxPendingPerConversation rejection).
146
+ */
147
+ export declare class SerialQueue {
148
+ private tail;
149
+ private depth;
150
+ private readonly maxDepth;
151
+ constructor(maxDepth?: number);
152
+ run<T>(task: () => Promise<T>): Promise<T>;
153
+ /** True when no task is queued or running. */
154
+ get idle(): boolean;
155
+ }
104
156
  export declare class SlackAdapter {
105
157
  private readonly api;
106
158
  private readonly responder;
@@ -112,11 +164,38 @@ export declare class SlackAdapter {
112
164
  private readonly stripMentionText;
113
165
  private readonly streamOptions;
114
166
  private readonly messages;
167
+ private readonly attachmentMaxBytes;
168
+ private readonly allowedMimeTypes;
115
169
  private readonly logger;
116
- private readonly activeRuns;
170
+ /**
171
+ * In-flight abort controllers per thread. The harness serializes runs for a
172
+ * conversation, so several may be queued/active concurrently; /cancel aborts
173
+ * every controller for the thread (and clears the harness queue via
174
+ * responder.cancel).
175
+ */
176
+ private readonly activeControllers;
177
+ /**
178
+ * Per-conversation admission queue. Socket Mode dispatches envelopes
179
+ * concurrently, and pre-submit work (status + file download) is variable
180
+ * latency, so without this a later same-thread message could reach
181
+ * responder.respond() (and the harness FIFO) before an earlier one. We
182
+ * serialize respondToEvent per conversation to preserve message order.
183
+ * /cancel stays out-of-band (handled before this queue).
184
+ */
185
+ private readonly admissionQueues;
117
186
  constructor(options: SlackAdapterOptions);
118
187
  handleEventCallback(callback: SlackEventCallback): Promise<SlackEventHandlingResult>;
119
188
  private respondToEvent;
189
+ /**
190
+ * Download each inbound Slack file's bytes into an {@link AgentAttachment}.
191
+ * Files with a missing/disallowed mimetype, no private URL, or an advertised
192
+ * size over the cap are skipped before any network call. The byte cap is also
193
+ * enforced during the download. A failed download skips that file and
194
+ * continues; downloads are tied to the request abort signal.
195
+ */
196
+ private downloadAttachments;
197
+ private registerController;
198
+ private unregisterController;
120
199
  private normalizeEventCallback;
121
200
  private prepareText;
122
201
  private isAuthorized;
@@ -1 +1 @@
1
- {"version":3,"file":"adapter.d.ts","sourceRoot":"","sources":["../src/adapter.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"adapter.d.ts","sourceRoot":"","sources":["../src/adapter.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,eAAe,EACpB,KAAK,gBAAgB,EACrB,KAAK,cAAc,IAAI,oBAAoB,EAC3C,KAAK,aAAa,EACnB,MAAM,6BAA6B,CAAC;AAErC,OAAO,EAEL,KAAK,kBAAkB,EACvB,KAAK,wBAAwB,EAE9B,MAAM,qBAAqB,CAAC;AAC7B,OAAO,KAAK,EACV,cAAc,EAEd,kBAAkB,EAElB,cAAc,EACd,WAAW,EACX,WAAW,EACZ,MAAM,YAAY,CAAC;AAEpB,MAAM,MAAM,gBAAgB,GAAG,QAAQ,GAAG,aAAa,CAAC;AAExD,MAAM,WAAW,YAAa,SAAQ,gBAAgB;IACpD,cAAc,EAAE,MAAM,CAAC;IACvB,SAAS,EAAE,cAAc,CAAC;IAC1B,SAAS,EAAE,cAAc,CAAC;IAC1B,QAAQ,EAAE,cAAc,CAAC;IACzB,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,gBAAgB,CAAC;IAC1B,WAAW,EAAE,WAAW,CAAC;IACzB,WAAW,CAAC,EAAE,SAAS,eAAe,EAAE,CAAC;IACzC,QAAQ,EAAE;QACR,KAAK,EAAE,oBAAoB,CAAC;QAC5B,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;KACxB,CAAC;CACH;AAED,+DAA+D;AAC/D,MAAM,WAAW,sBAAsB;IACrC;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;OAGG;IACH,gBAAgB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CACtC;AAED,MAAM,WAAW,oBAAoB;IACnC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE;QACP,EAAE,EAAE,cAAc,CAAC;QACnB,IAAI,CAAC,EAAE,MAAM,CAAC;KACf,CAAC;IACF,OAAO,EAAE;QACP,EAAE,EAAE,cAAc,CAAC;QACnB,QAAQ,CAAC,EAAE,cAAc,CAAC;QAC1B,OAAO,CAAC,EAAE,cAAc,CAAC;KAC1B,CAAC;IACF,IAAI,CAAC,EAAE;QACL,EAAE,EAAE,WAAW,CAAC;KACjB,CAAC;IACF,OAAO,EAAE,gBAAgB,CAAC;CAC3B;AAED,YAAY,EAAE,aAAa,EAAE,CAAC;AAC9B,MAAM,MAAM,cAAc,GAAG,oBAAoB,CAAC,YAAY,EAAE,kBAAkB,EAAE,aAAa,CAAC,CAAC;AAEnG,MAAM,WAAW,oBAAoB;IACnC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED,MAAM,WAAW,yBAAyB;IACxC,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB;;;OAGG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAED,MAAM,WAAW,kBAAmB,SAAQ,wBAAwB;IAClE,KAAK,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IAClE,IAAI,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;CAClE;AAED,MAAM,WAAW,mBAAmB;IAClC,GAAG,EAAE,WAAW,CAAC;IACjB,SAAS,EAAE,cAAc,CAAC;IAC1B,iBAAiB,CAAC,EAAE,cAAc,EAAE,CAAC;IACrC,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B,UAAU,CAAC,EAAE,WAAW,EAAE,CAAC;IAC3B,kBAAkB,CAAC,EAAE,MAAM,EAAE,CAAC;IAC9B,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B,MAAM,CAAC,EAAE,yBAAyB,CAAC;IACnC,QAAQ,CAAC,EAAE,oBAAoB,CAAC;IAChC,WAAW,CAAC,EAAE,sBAAsB,CAAC;IACrC,MAAM,CAAC,EAAE,kBAAkB,CAAC;CAC7B;AAED,MAAM,MAAM,uBAAuB,GAC/B,mBAAmB,GACnB,qBAAqB,GACrB,YAAY,GACZ,uBAAuB,GACvB,UAAU,GACV,WAAW,CAAC;AAEhB,MAAM,MAAM,wBAAwB,GAChC;IACE,IAAI,EAAE,SAAS,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,cAAc,CAAC;IAC1B,MAAM,EAAE,SAAS,GAAG,WAAW,CAAC;IAChC,OAAO,CAAC,EAAE,OAAO,GAAG,MAAM,CAAC;IAC3B,OAAO,EAAE,gBAAgB,CAAC;IAC1B,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACpC,GACD;IACE,IAAI,EAAE,SAAS,CAAC;IAChB,MAAM,EAAE,uBAAuB,CAAC;IAChC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,SAAS,CAAC,EAAE,cAAc,CAAC;CAC5B,GACD;IACE,IAAI,EAAE,cAAc,CAAC;IACrB,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,cAAc,CAAC;CAC3B,GACD;IACE,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,cAAc,CAAC;CAC3B,GACD;IACE,IAAI,EAAE,WAAW,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,cAAc,CAAC;CAC3B,GACD;IACE,IAAI,EAAE,OAAO,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,cAAc,CAAC;IAC3B,KAAK,EAAE,OAAO,CAAC;CAChB,CAAC;AA8DN;;;;GAIG;AACH,qBAAa,oBAAqB,SAAQ,KAAK;IAC7C,QAAQ,CAAC,IAAI,EAAG,mBAAmB,CAAU;gBAEjC,QAAQ,EAAE,MAAM;CAI7B;AAED;;;;;;;;;GASG;AACH,qBAAa,WAAW;IACtB,OAAO,CAAC,IAAI,CAAoC;IAChD,OAAO,CAAC,KAAK,CAAK;IAClB,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;gBAEtB,QAAQ,GAAE,MAA0C;IAIhE,GAAG,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC;IAc1C,8CAA8C;IAC9C,IAAI,IAAI,IAAI,OAAO,CAElB;CACF;AAMD,qBAAa,YAAY;IACvB,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAc;IAClC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAiB;IAC3C,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAU;IAC3C,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAAc;IAChD,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAc;IACzC,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAoB;IAClD,OAAO,CAAC,QAAQ,CAAC,kBAAkB,CAAoB;IACvD,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAU;IAC3C,OAAO,CAAC,QAAQ,CAAC,aAAa,CAA4B;IAC1D,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAiC;IAC1D,OAAO,CAAC,QAAQ,CAAC,kBAAkB,CAAS;IAC5C,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAsB;IACvD,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAiC;IACxD;;;;;OAKG;IACH,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAA2C;IAC7E;;;;;;;OAOG;IACH,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAkC;gBAEtD,OAAO,EAAE,mBAAmB;IA+BlC,mBAAmB,CACvB,QAAQ,EAAE,kBAAkB,GAC3B,OAAO,CAAC,wBAAwB,CAAC;YAoItB,cAAc;IAoG5B;;;;;;OAMG;YACW,mBAAmB;IAkEjC,OAAO,CAAC,kBAAkB;IAS1B,OAAO,CAAC,oBAAoB;IAW5B,OAAO,CAAC,sBAAsB;IAqE9B,OAAO,CAAC,WAAW;IAkBnB,OAAO,CAAC,YAAY;CAGrB"}
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,25 @@ export class SlackAdapter {
20
92
  stripMentionText;
21
93
  streamOptions;
22
94
  messages;
95
+ attachmentMaxBytes;
96
+ allowedMimeTypes;
23
97
  logger;
24
- activeRuns = new Map();
98
+ /**
99
+ * In-flight abort controllers per thread. The harness serializes runs for a
100
+ * conversation, so several may be queued/active concurrently; /cancel aborts
101
+ * every controller for the thread (and clears the harness queue via
102
+ * responder.cancel).
103
+ */
104
+ activeControllers = new Map();
105
+ /**
106
+ * Per-conversation admission queue. Socket Mode dispatches envelopes
107
+ * concurrently, and pre-submit work (status + file download) is variable
108
+ * latency, so without this a later same-thread message could reach
109
+ * responder.respond() (and the harness FIFO) before an earlier one. We
110
+ * serialize respondToEvent per conversation to preserve message order.
111
+ * /cancel stays out-of-band (handled before this queue).
112
+ */
113
+ admissionQueues = new Map();
25
114
  constructor(options) {
26
115
  this.api = options.api;
27
116
  this.responder = options.responder;
@@ -34,6 +123,8 @@ export class SlackAdapter {
34
123
  options.stripMentionText ?? (this.botUserIds.size > 0 || this.mentionTextAliases.length > 0);
35
124
  this.streamOptions = options.stream ?? {};
36
125
  this.messages = { ...DEFAULT_MESSAGES, ...options.messages };
126
+ this.attachmentMaxBytes = options.attachments?.maxBytes ?? DEFAULT_ATTACHMENT_MAX_BYTES;
127
+ this.allowedMimeTypes = new Set((options.attachments?.allowedMimeTypes ?? DEFAULT_ALLOWED_MIME_TYPES).map((mime) => mime.trim().toLowerCase()));
37
128
  this.logger = options.logger;
38
129
  if (!this.allowAllChannels && this.allowedChannelIds.size === 0) {
39
130
  throw new TypeError("SlackAdapter requires allowedChannelIds or allowAllChannels: true.");
@@ -58,7 +149,7 @@ export class SlackAdapter {
58
149
  };
59
150
  }
60
151
  const text = event.text.trim();
61
- if (text.length === 0) {
152
+ if (text.length === 0 && event.files.length === 0) {
62
153
  await this.api.chatPostMessage({
63
154
  channel: event.channelId,
64
155
  text: this.messages.unsupportedText,
@@ -103,10 +194,15 @@ export class SlackAdapter {
103
194
  };
104
195
  }
105
196
  const runKey = runKeyFor(event);
106
- const activeRun = this.activeRuns.get(runKey);
107
197
  if (command?.name === "cancel") {
108
- if (activeRun !== undefined) {
109
- activeRun.controller.abort(new Error("Cancelled by Slack user."));
198
+ // Clear any queued follow-ups for the conversation (the harness owns the
199
+ // queue) and abort every in-flight controller for this thread.
200
+ this.responder.cancel?.(conversationIdFor(event), new Error("Cancelled by Slack user."));
201
+ const controllers = this.activeControllers.get(runKey);
202
+ if (controllers !== undefined) {
203
+ for (const controller of controllers) {
204
+ controller.abort(new Error("Cancelled by Slack user."));
205
+ }
110
206
  }
111
207
  await this.api.chatPostMessage({
112
208
  channel: event.channelId,
@@ -119,28 +215,58 @@ export class SlackAdapter {
119
215
  channelId: event.channelId,
120
216
  };
121
217
  }
122
- if (activeRun !== undefined) {
123
- await this.api.chatPostMessage({
124
- channel: event.channelId,
125
- text: this.messages.busyText,
126
- thread_ts: event.threadTs,
127
- });
128
- return {
129
- kind: "busy",
130
- eventId: event.eventId,
131
- channelId: event.channelId,
132
- };
218
+ // No per-thread "busy" rejection: the harness serializes runs for the
219
+ // conversation, queuing a concurrent message and answering it on the warm
220
+ // session after the current turn. We admit messages through a per-conversation
221
+ // serial queue first so they reach the harness in arrival order even when an
222
+ // earlier message stalls on file download.
223
+ const conversationId = conversationIdFor(event);
224
+ // Create and register the controller BEFORE entering the admission queue so a
225
+ // /cancel can abort a message still parked behind an earlier same-thread run.
226
+ // respondToEvent's first abort check then makes the queued-then-cancelled run
227
+ // bail before responder.respond and post only the cancelled terminal.
228
+ const controller = new AbortController();
229
+ this.registerController(runKey, controller);
230
+ let queue = this.admissionQueues.get(conversationId);
231
+ if (queue === undefined) {
232
+ queue = new SerialQueue();
233
+ this.admissionQueues.set(conversationId, queue);
234
+ }
235
+ try {
236
+ return await queue.run(() => this.respondToEvent(event, text, runKey, controller));
237
+ }
238
+ catch (error) {
239
+ if (isSerialQueueFullError(error)) {
240
+ // Over-cap: the task was rejected BEFORE entering the queue, so
241
+ // respondToEvent (and its finally) never ran. Unregister the eagerly
242
+ // created controller here so it does not leak in activeControllers, then
243
+ // answer with the busy terminal instead of admitting an unbounded backlog.
244
+ this.unregisterController(runKey, controller);
245
+ await this.api.chatPostMessage({
246
+ channel: event.channelId,
247
+ text: this.messages.busyText,
248
+ thread_ts: event.threadTs,
249
+ });
250
+ return { kind: "busy", eventId: event.eventId, channelId: event.channelId };
251
+ }
252
+ throw error;
253
+ }
254
+ finally {
255
+ if (queue.idle && this.admissionQueues.get(conversationId) === queue) {
256
+ this.admissionQueues.delete(conversationId);
257
+ }
133
258
  }
134
- return await this.respondToEvent(event, text, runKey);
135
259
  }
136
- async respondToEvent(event, text, runKey) {
137
- const controller = new AbortController();
138
- const activeRun = { controller };
139
- this.activeRuns.set(runKey, activeRun);
260
+ async respondToEvent(event, text, runKey, controller) {
140
261
  const streamOptions = {
141
262
  api: this.api,
142
263
  channelId: event.channelId,
143
264
  threadTs: event.threadTs,
265
+ reactToTs: event.messageTs,
266
+ // Default to a πŸ‘€ "seen" reaction + final-answer-only delivery (no streamed
267
+ // interim edits); a tuning override can restore interim streaming.
268
+ finalOnly: this.streamOptions.finalOnly ?? true,
269
+ abortSignal: controller.signal,
144
270
  };
145
271
  if (this.streamOptions.initialStatusText !== undefined) {
146
272
  streamOptions.initialStatusText = this.streamOptions.initialStatusText;
@@ -151,6 +277,18 @@ export class SlackAdapter {
151
277
  if (this.streamOptions.maxMessageChars !== undefined) {
152
278
  streamOptions.maxMessageChars = this.streamOptions.maxMessageChars;
153
279
  }
280
+ if (this.streamOptions.maxSendRetries !== undefined) {
281
+ streamOptions.maxSendRetries = this.streamOptions.maxSendRetries;
282
+ }
283
+ if (this.streamOptions.retryCapMs !== undefined) {
284
+ streamOptions.retryCapMs = this.streamOptions.retryCapMs;
285
+ }
286
+ if (this.streamOptions.retryBaseDelayMs !== undefined) {
287
+ streamOptions.retryBaseDelayMs = this.streamOptions.retryBaseDelayMs;
288
+ }
289
+ if (this.streamOptions.showHints !== undefined) {
290
+ streamOptions.showHints = this.streamOptions.showHints;
291
+ }
154
292
  if (this.logger !== undefined) {
155
293
  streamOptions.logger = this.logger;
156
294
  }
@@ -161,7 +299,20 @@ export class SlackAdapter {
161
299
  await stream.finish(this.messages.cancelledText);
162
300
  return { kind: "cancelled", eventId: event.eventId, channelId: event.channelId };
163
301
  }
164
- const request = buildAgentRequest(event, text, controller.signal);
302
+ const attachments = await this.downloadAttachments(event.files, controller.signal);
303
+ if (controller.signal.aborted) {
304
+ await stream.finish(this.messages.cancelledText);
305
+ return { kind: "cancelled", eventId: event.eventId, channelId: event.channelId };
306
+ }
307
+ // A file-only message whose files were all skipped (MIME/size/missing
308
+ // URL/download failure) leaves no text and no attachments. Deliver a
309
+ // deterministic message instead of submitting an empty request the harness
310
+ // would reject.
311
+ if (text.length === 0 && attachments.length === 0) {
312
+ await stream.finish(this.messages.unsupportedText);
313
+ return { kind: "ignored", reason: "no_usable_attachments", eventId: event.eventId, channelId: event.channelId };
314
+ }
315
+ const request = buildAgentRequest(event, text, controller.signal, attachments);
165
316
  const response = await this.responder.respond(request, stream);
166
317
  if (controller.signal.aborted) {
167
318
  await stream.finish(this.messages.cancelledText);
@@ -192,9 +343,89 @@ export class SlackAdapter {
192
343
  return { kind: "error", eventId: event.eventId, channelId: event.channelId, error };
193
344
  }
194
345
  finally {
195
- if (this.activeRuns.get(runKey) === activeRun) {
196
- this.activeRuns.delete(runKey);
346
+ this.unregisterController(runKey, controller);
347
+ }
348
+ }
349
+ /**
350
+ * Download each inbound Slack file's bytes into an {@link AgentAttachment}.
351
+ * Files with a missing/disallowed mimetype, no private URL, or an advertised
352
+ * size over the cap are skipped before any network call. The byte cap is also
353
+ * enforced during the download. A failed download skips that file and
354
+ * continues; downloads are tied to the request abort signal.
355
+ */
356
+ async downloadAttachments(files, signal) {
357
+ const attachments = [];
358
+ // downloadFile is optional on SlackWebApi: a text-only custom client may omit
359
+ // it. Without it we cannot fetch bytes, so skip attachment download entirely.
360
+ if (typeof this.api.downloadFile !== "function") {
361
+ if (files.length > 0) {
362
+ this.logger?.debug?.("Slack client has no downloadFile; skipping attachments.", { count: files.length });
363
+ }
364
+ return attachments;
365
+ }
366
+ const downloadFile = this.api.downloadFile.bind(this.api);
367
+ for (const file of files) {
368
+ if (signal.aborted) {
369
+ break;
370
+ }
371
+ const mimeType = typeof file.mimetype === "string" ? file.mimetype : "";
372
+ if (mimeType.length === 0 || !this.allowedMimeTypes.has(mimeType.toLowerCase())) {
373
+ this.logger?.debug?.("Skipped Slack file with disallowed mimetype.", {
374
+ id: file.id,
375
+ mimeType,
376
+ });
377
+ continue;
378
+ }
379
+ if (typeof file.size === "number" && file.size > this.attachmentMaxBytes) {
380
+ this.logger?.debug?.("Skipped Slack file exceeding the byte cap.", {
381
+ id: file.id,
382
+ size: file.size,
383
+ });
384
+ continue;
385
+ }
386
+ const url = file.url_private ?? file.url_private_download;
387
+ if (typeof url !== "string" || url.length === 0) {
388
+ this.logger?.debug?.("Skipped Slack file without a private URL.", { id: file.id });
389
+ continue;
390
+ }
391
+ let bytes;
392
+ try {
393
+ bytes = await downloadFile({ url, maxBytes: this.attachmentMaxBytes }, { signal });
197
394
  }
395
+ catch (error) {
396
+ this.logger?.warn?.("Failed to download a Slack file (skipped).", {
397
+ id: file.id,
398
+ error: error instanceof Error ? error.message : String(error),
399
+ });
400
+ continue;
401
+ }
402
+ if (bytes.byteLength > this.attachmentMaxBytes) {
403
+ this.logger?.debug?.("Skipped Slack file exceeding the byte cap.", {
404
+ id: file.id,
405
+ size: bytes.byteLength,
406
+ });
407
+ continue;
408
+ }
409
+ attachments.push(buildAttachment(file, mimeType, bytes));
410
+ }
411
+ return attachments;
412
+ }
413
+ registerController(runKey, controller) {
414
+ let controllers = this.activeControllers.get(runKey);
415
+ if (controllers === undefined) {
416
+ controllers = new Set();
417
+ this.activeControllers.set(runKey, controllers);
418
+ }
419
+ controllers.add(controller);
420
+ }
421
+ unregisterController(runKey, controller) {
422
+ const controllers = this.activeControllers.get(runKey);
423
+ if (controllers === undefined) {
424
+ return;
425
+ }
426
+ controllers.delete(controller);
427
+ if (controllers.size === 0) {
428
+ this.activeControllers.delete(runKey);
198
429
  }
199
430
  }
200
431
  normalizeEventCallback(callback) {
@@ -210,16 +441,19 @@ export class SlackAdapter {
210
441
  if (rawEvent.user !== undefined && this.botUserIds.has(normalizeIdForMatch(rawEvent.user))) {
211
442
  return ignored("from_self", callback, rawEvent);
212
443
  }
213
- if (rawEvent.subtype !== undefined) {
444
+ if (rawEvent.subtype !== undefined && !FILE_BEARING_MESSAGE_SUBTYPES.has(rawEvent.subtype)) {
214
445
  return ignored("unsupported_message", callback, rawEvent);
215
446
  }
216
447
  if (eventType === "message" &&
217
448
  rawEvent.channel_type !== "im") {
218
449
  return ignored("unsupported_event", callback, rawEvent);
219
450
  }
451
+ // A file upload may arrive with no caption (text absent/empty); accept it as
452
+ // long as it carries files so the attachment is not dropped.
453
+ const hasFiles = Array.isArray(rawEvent.files) && rawEvent.files.length > 0;
220
454
  if (typeof rawEvent.channel !== "string" ||
221
455
  typeof rawEvent.ts !== "string" ||
222
- typeof rawEvent.text !== "string") {
456
+ (typeof rawEvent.text !== "string" && !hasFiles)) {
223
457
  return ignored("unsupported_message", callback, rawEvent);
224
458
  }
225
459
  const threadTs = typeof rawEvent.thread_ts === "string" && rawEvent.thread_ts.trim().length > 0
@@ -228,10 +462,11 @@ export class SlackAdapter {
228
462
  const event = {
229
463
  eventId: callback.event_id,
230
464
  channelId: rawEvent.channel,
231
- text: this.prepareText(rawEvent.text),
465
+ text: this.prepareText(typeof rawEvent.text === "string" ? rawEvent.text : ""),
232
466
  messageTs: rawEvent.ts,
233
467
  threadTs,
234
468
  trigger,
469
+ files: Array.isArray(rawEvent.files) ? rawEvent.files : [],
235
470
  };
236
471
  if (callback.team_id !== undefined) {
237
472
  event.teamId = callback.team_id;
@@ -293,7 +528,7 @@ function ignored(reason, callback, event) {
293
528
  }
294
529
  return result;
295
530
  }
296
- function buildAgentRequest(event, text, abortSignal) {
531
+ function buildAgentRequest(event, text, abortSignal, attachments) {
297
532
  const metadata = {
298
533
  eventId: event.eventId,
299
534
  channel: {
@@ -326,7 +561,7 @@ function buildAgentRequest(event, text, abortSignal) {
326
561
  metadata.user = { id: event.userId };
327
562
  }
328
563
  const request = {
329
- conversationId: `slack:${event.channelId}:${event.threadTs}`,
564
+ conversationId: conversationIdFor(event),
330
565
  channelId: event.channelId,
331
566
  messageTs: event.messageTs,
332
567
  threadTs: event.threadTs,
@@ -342,8 +577,36 @@ function buildAgentRequest(event, text, abortSignal) {
342
577
  if (event.userId !== undefined) {
343
578
  request.userId = event.userId;
344
579
  }
580
+ if (attachments.length > 0) {
581
+ request.attachments = attachments;
582
+ }
345
583
  return request;
346
584
  }
585
+ const TEXT_MIME_PATTERN = /^text\/|[/+](?:json|xml|csv)$/iu;
586
+ /** Build an {@link AgentAttachment} from downloaded Slack file bytes. */
587
+ function buildAttachment(file, mimeType, bytes) {
588
+ const attachment = {
589
+ kind: mimeType.toLowerCase().startsWith("image/") ? "image" : "document",
590
+ mimeType,
591
+ data: toBase64(bytes),
592
+ sizeBytes: bytes.byteLength,
593
+ };
594
+ const name = typeof file.name === "string" && file.name.length > 0
595
+ ? file.name
596
+ : typeof file.title === "string" && file.title.length > 0
597
+ ? file.title
598
+ : undefined;
599
+ if (name !== undefined) {
600
+ attachment.name = name;
601
+ }
602
+ if (TEXT_MIME_PATTERN.test(mimeType)) {
603
+ attachment.text = new TextDecoder("utf-8").decode(bytes);
604
+ }
605
+ return attachment;
606
+ }
607
+ function toBase64(bytes) {
608
+ return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString("base64");
609
+ }
347
610
  function parseCommand(text) {
348
611
  const match = text.match(/^\/([A-Za-z0-9_]+)(?:\s|$)/u);
349
612
  if (match?.[1] === undefined) {
@@ -364,6 +627,13 @@ async function finishSafely(stream, text, logger) {
364
627
  function runKeyFor(event) {
365
628
  return `${event.channelId}:${event.threadTs}`;
366
629
  }
630
+ /**
631
+ * The conversation key the harness serializes runs on. /cancel uses the same id
632
+ * so the responder clears the right conversation's queued follow-ups.
633
+ */
634
+ function conversationIdFor(event) {
635
+ return `slack:${event.channelId}:${event.threadTs}`;
636
+ }
367
637
  function normalizeIdForMatch(value) {
368
638
  return value.trim().toLowerCase();
369
639
  }