@mono-agent/slack-adapter 0.3.0 β 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 +11 -0
- package/dist/adapter.d.ts +82 -3
- package/dist/adapter.d.ts.map +1 -1
- package/dist/adapter.js +299 -29
- package/dist/adapter.js.map +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/message-stream.d.ts +67 -29
- package/dist/message-stream.d.ts.map +1 -1
- package/dist/message-stream.js +178 -136
- package/dist/message-stream.js.map +1 -1
- package/dist/slack-client.d.ts +21 -2
- package/dist/slack-client.d.ts.map +1 -1
- package/dist/slack-client.js +162 -4
- package/dist/slack-client.js.map +1 -1
- package/dist/start.d.ts +2 -1
- package/dist/start.d.ts.map +1 -1
- package/dist/start.js +3 -0
- package/dist/start.js.map +1 -1
- package/dist/types.d.ts +64 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +3 -3
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
|
-
|
|
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;
|
package/dist/adapter.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"adapter.d.ts","sourceRoot":"","sources":["../src/adapter.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,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,
|
|
1
|
+
{"version":3,"file":"adapter.d.ts","sourceRoot":"","sources":["../src/adapter.ts"],"names":[],"mappings":"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"}
|
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
|
-
|
|
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
|
-
|
|
109
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
|
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
|
-
|
|
196
|
-
|
|
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:
|
|
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
|
}
|