@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/README.md +12 -1
- package/dist/adapter.d.ts +276 -4
- package/dist/adapter.d.ts.map +1 -1
- package/dist/adapter.js +706 -27
- package/dist/adapter.js.map +1 -1
- package/dist/config.d.ts +58 -2
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +213 -78
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +7 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/message-stream.d.ts +78 -29
- package/dist/message-stream.d.ts.map +1 -1
- package/dist/message-stream.js +184 -136
- package/dist/message-stream.js.map +1 -1
- package/dist/slack-client.d.ts +22 -2
- package/dist/slack-client.d.ts.map +1 -1
- package/dist/slack-client.js +165 -4
- package/dist/slack-client.js.map +1 -1
- package/dist/socket-mode-runner.d.ts +110 -1
- package/dist/socket-mode-runner.d.ts.map +1 -1
- package/dist/socket-mode-runner.js +287 -22
- package/dist/socket-mode-runner.js.map +1 -1
- package/dist/start.d.ts +27 -3
- package/dist/start.d.ts.map +1 -1
- package/dist/start.js +49 -0
- package/dist/start.js.map +1 -1
- package/dist/types.d.ts +157 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -3
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
109
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
|
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
|
-
|
|
196
|
-
|
|
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
|
|
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
|