@assemblyline-agents/slack 2.0.0 → 3.0.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/dist/index.js CHANGED
@@ -1,8 +1,31 @@
1
1
  import { createHmac, timingSafeEqual } from "node:crypto";
2
2
  import { connectionPluginMetadata, defineChannel, defineMcpPluginConnection, definePlugin } from "@assemblyline-agents/core";
3
3
  import { attachmentFetchTimeoutMs, attachmentRemoteUrl, attachmentTrustedForProvider, fetchWithPolicy, firstDownloadableUrl } from "@assemblyline-agents/runtime";
4
+ import { augmentContext } from "./context.js";
5
+ import { errorDetail } from "./error-detail.js";
6
+ import { externalSlackMessageId, slackAttachments, slackConversationId, slackMessageSource, slackMessageText, slackTimestampToIso } from "./message-context.js";
7
+ import { splitSlackMarkdown } from "./outbound-markdown.js";
8
+ import { slackApi } from "./slack-api.js";
9
+ import { slackSigningSecrets, slackWorkspaceCredentials } from "./workspace-credentials.js";
10
+ export { slackSigningSecrets, slackWorkspaceCredentialMap, slackWorkspaceCredentials } from "./workspace-credentials.js";
11
+ export { augmentContext } from "./context.js";
4
12
  export const SLACK_REQUIRED_ENV = ["SLACK_SIGNING_SECRET", "SLACK_BOT_TOKEN"];
5
- export const SLACK_OPTIONAL_ENV = ["SLACK_BOT_USER_ID", "SLACK_ASSISTANT_ENABLED"];
13
+ export const SLACK_OPTIONAL_ENV = ["SLACK_BOT_USER_ID", "SLACK_ASSISTANT_ENABLED", "SLACK_WORKSPACE_CREDENTIALS_JSON"];
14
+ export const SLACK_REQUIRED_BOT_SCOPES = ["app_mentions:read", "channels:history", "chat:write", "files:read", "files:write", "im:history"];
15
+ export const SLACK_OPTIONAL_BOT_SCOPES = ["assistant:write", "groups:history"];
16
+ export const SLACK_DEFAULT_STATUS = "is working...";
17
+ export const SLACK_DEFAULT_LOADING_MESSAGES = [
18
+ "Beboppin'",
19
+ "Boondoggling",
20
+ "Cerebrating",
21
+ "Combobulating",
22
+ "Cooking",
23
+ "Elucidating",
24
+ "Flibbertigibbeting",
25
+ "Hullaballooing",
26
+ "Ionizing",
27
+ "Photosynthesizing"
28
+ ];
6
29
  /** Production ingress-auth requirement (any-of groups): Slack request signing. */
7
30
  export const SLACK_INGRESS_SECRET_ENV = [["SLACK_SIGNING_SECRET"]];
8
31
  const SLACK_CONNECTION_PLUGIN = connectionPluginMetadata("slack");
@@ -12,11 +35,17 @@ export function defineSlackConnection(options) {
12
35
  export const assemblyLinePlugin = definePlugin({ connections: [SLACK_CONNECTION_PLUGIN] });
13
36
  const DEFAULT_SLACK_ROUTE = "/slack/events";
14
37
  const DEFAULT_SLACK_WEBHOOK_TOLERANCE_SECONDS = 300;
15
- const SLACK_RECENT_ACTIVITY_HOURS = 24;
16
- const SLACK_RECENT_ACTIVITY_LIMIT = 3;
17
- const SLACK_RECENT_HISTORY_LIMIT = 20;
18
- const SLACK_SUMMARY_MAX_CHARS = 240;
19
- const SLACK_DELIVERY_FILE_MAX_COUNT = 10;
38
+ const SLACK_STATUS_REFRESH_INTERVAL_MS = 90_000;
39
+ function shuffledSlackLoadingMessages() {
40
+ const messages = [...SLACK_DEFAULT_LOADING_MESSAGES];
41
+ for (let index = messages.length - 1; index > 0; index -= 1) {
42
+ const swapIndex = Math.floor(Math.random() * (index + 1));
43
+ const current = messages[index];
44
+ messages[index] = messages[swapIndex];
45
+ messages[swapIndex] = current;
46
+ }
47
+ return messages;
48
+ }
20
49
  export function defineSlackChannel(options = {}) {
21
50
  const definition = {
22
51
  description: options.description ?? "Receive Slack Events API callbacks and deliver replies through Slack Web API.",
@@ -25,8 +54,12 @@ export function defineSlackChannel(options = {}) {
25
54
  methods: options.methods ?? ["POST"],
26
55
  ingress: { requiredSecretEnv: SLACK_INGRESS_SECRET_ENV },
27
56
  metadata: {
57
+ ...(options.metadata ?? {}),
28
58
  provider: "slack",
29
- ...(options.metadata ?? {})
59
+ installation: {
60
+ requiredBotScopes: [...SLACK_REQUIRED_BOT_SCOPES],
61
+ optionalBotScopes: [...SLACK_OPTIONAL_BOT_SCOPES]
62
+ }
30
63
  }
31
64
  };
32
65
  if (options.connection)
@@ -34,6 +67,7 @@ export function defineSlackChannel(options = {}) {
34
67
  return Object.assign(defineChannel(definition), {
35
68
  normalizeHttp,
36
69
  ...(options.resolvePrincipal ? { resolvePrincipal: options.resolvePrincipal } : {}),
70
+ isPrivateSurface: options.isPrivateSurface ?? defaultSlackPrivateSurface,
37
71
  startTurn,
38
72
  augmentContext,
39
73
  send,
@@ -41,14 +75,21 @@ export function defineSlackChannel(options = {}) {
41
75
  ingressAuth: { requiredSecretEnv: SLACK_INGRESS_SECRET_ENV }
42
76
  });
43
77
  }
78
+ function defaultSlackPrivateSurface(turn) {
79
+ const metadata = recordValue(turn.metadata);
80
+ const slack = recordValue(metadata?.slack);
81
+ const surface = stringValue(slack?.surface);
82
+ return surface === "dm" || surface === "assistant";
83
+ }
44
84
  export function normalizeHttp(request, ctx) {
45
- const signingSecret = ctx.env.SLACK_SIGNING_SECRET;
46
- if (!signingSecret)
85
+ const payload = request.body;
86
+ const teamId = slackTeamId(recordValue(payload.event) ?? {}, payload);
87
+ const signingSecrets = slackSigningSecrets(ctx.env, teamId);
88
+ if (signingSecrets.length === 0)
47
89
  return { kind: "response", status: 500, body: { ok: false, error: "missing_slack_signing_secret" } };
48
- if (!verifySlackRequest(request, signingSecret)) {
90
+ if (!signingSecrets.some((secret) => verifySlackRequest(request, secret))) {
49
91
  return { kind: "response", status: 401, body: { ok: false, error: "invalid_slack_signature" } };
50
92
  }
51
- const payload = request.body;
52
93
  if (payload.type === "url_verification") {
53
94
  return { kind: "response", status: 200, body: { challenge: stringValue(payload.challenge) ?? "" } };
54
95
  }
@@ -61,119 +102,156 @@ export function normalizeHttp(request, ctx) {
61
102
  const event = recordValue(payload.event);
62
103
  if (!event)
63
104
  return ignored("missing_event", { eventId });
64
- const ignoredReason = ignoredEventReason(event, ctx.env);
105
+ const ignoredReason = ignoredEventReason(event, payload, ctx.env, teamId);
65
106
  if (ignoredReason)
66
107
  return ignored(ignoredReason, { eventId, eventType: stringValue(event.type) ?? null });
67
108
  const normalized = slackTurnFromEvent(event, payload, eventId, ctx);
68
- if (!normalized) {
69
- return ignored("unsupported_event", { eventId, eventType: stringValue(event.type) ?? null });
109
+ if (normalized) {
110
+ return {
111
+ kind: "accepted",
112
+ status: 200,
113
+ body: { ok: true, accepted: true },
114
+ idempotencyKey: eventId,
115
+ idempotencyScope: `channel:${ctx.channel.name}:slack`,
116
+ turn: normalized
117
+ };
118
+ }
119
+ const observation = slackObservationFromEvent(event, payload, eventId, ctx);
120
+ if (observation) {
121
+ return {
122
+ kind: "observation",
123
+ status: 200,
124
+ body: { ok: true, observed: true },
125
+ observation
126
+ };
70
127
  }
71
128
  return {
72
- kind: "accepted",
73
- status: 200,
74
- body: { ok: true, accepted: true },
75
- idempotencyKey: eventId,
76
- idempotencyScope: `channel:${ctx.channel.name}:slack`,
77
- turn: normalized
129
+ ...ignored("unsupported_event", { eventId, eventType: stringValue(event.type) ?? null })
78
130
  };
79
131
  }
80
132
  export async function startTurn(turn, ctx) {
81
- if (!isTruthy(ctx.env.SLACK_ASSISTANT_ENABLED))
82
- return undefined;
83
133
  const target = slackDeliveryTarget(turn);
84
- if (!target.channel || !target.threadTs || !ctx.env.SLACK_BOT_TOKEN)
134
+ const teamId = slackTeamIdForTurn(turn);
135
+ const threadTs = target.threadTs ?? target.messageTs;
136
+ if (!target.channel || !threadTs || !slackWorkspaceCredentials(ctx.env, teamId).botToken)
85
137
  return undefined;
86
138
  const channel = target.channel;
87
- const threadTs = target.threadTs;
88
- await slackApi(ctx, "assistant.threads.setStatus", {
89
- channel_id: channel,
90
- thread_ts: threadTs,
91
- status: "Working on it..."
92
- }).catch(() => undefined);
139
+ const loadingMessages = shuffledSlackLoadingMessages();
140
+ const setWorkingStatus = async () => {
141
+ await slackApi(ctx, "assistant.threads.setStatus", {
142
+ channel_id: channel,
143
+ thread_ts: threadTs,
144
+ status: SLACK_DEFAULT_STATUS,
145
+ loading_messages: loadingMessages
146
+ }, teamId).then(() => undefined, () => undefined);
147
+ };
148
+ await setWorkingStatus();
149
+ let stopped = false;
150
+ let refreshInFlight;
151
+ const refreshTimer = setInterval(() => {
152
+ if (stopped || refreshInFlight)
153
+ return;
154
+ refreshInFlight = setWorkingStatus().finally(() => {
155
+ refreshInFlight = undefined;
156
+ });
157
+ }, SLACK_STATUS_REFRESH_INTERVAL_MS);
158
+ refreshTimer.unref?.();
93
159
  return {
94
160
  stop: async () => {
161
+ if (stopped)
162
+ return;
163
+ stopped = true;
164
+ clearInterval(refreshTimer);
165
+ await refreshInFlight;
95
166
  await slackApi(ctx, "assistant.threads.setStatus", {
96
167
  channel_id: channel,
97
168
  thread_ts: threadTs,
98
169
  status: ""
99
- }).catch(() => undefined);
170
+ }, teamId).catch(() => undefined);
100
171
  }
101
172
  };
102
173
  }
103
- export async function augmentContext(turn, ctx) {
104
- const recentHistory = (await ctx.state.listRecentMessages(turn.conversationId, SLACK_RECENT_HISTORY_LIMIT)).map(messageHistoryLine);
105
- const since = new Date(Date.now() - SLACK_RECENT_ACTIVITY_HOURS * 60 * 60 * 1000).toISOString();
106
- const relatedConversations = turn.userId && ctx.state.listRecentConversationsBySubject
107
- ? await ctx.state.listRecentConversationsBySubject({
108
- channel: turn.channel,
109
- subject: turn.userId,
110
- since,
111
- limit: SLACK_RECENT_ACTIVITY_LIMIT,
112
- excludeId: turn.conversationId
113
- })
114
- : [];
115
- const related = await Promise.all(relatedConversations.map((conversation) => summarizeConversation(ctx, conversation)));
116
- const slack = recordValue(turn.metadata?.slack);
117
- return {
118
- recentHistory,
119
- channelContext: compactJsonObject({
120
- provider: "slack",
121
- surface: stringValue(slack?.surface),
122
- teamId: stringValue(slack?.teamId),
123
- enterpriseId: stringValue(slack?.enterpriseId),
124
- channelId: stringValue(slack?.channel),
125
- threadTs: stringValue(slack?.threadTs),
126
- currentConversation: {
127
- id: turn.conversationId,
128
- userId: turn.userId ?? null,
129
- eventId: turn.eventId
130
- },
131
- relatedConversations: related,
132
- contextPolicy: {
133
- source: "openeve_state",
134
- maxRelatedConversations: SLACK_RECENT_ACTIVITY_LIMIT,
135
- maxAgeHours: SLACK_RECENT_ACTIVITY_HOURS,
136
- note: "Related Slack context is bounded to this user's recent OpenEve conversations and contains summaries, not workspace-wide Slack history."
137
- }
138
- })
139
- };
140
- }
141
174
  export async function send(delivery, ctx) {
142
175
  const target = slackDeliveryTarget(delivery.turn, delivery.payload);
176
+ const teamId = slackTeamIdForTurn(delivery.turn, delivery.payload);
143
177
  if (!target.channel)
144
178
  throw new Error("Slack delivery requires a channel.");
145
- const uploadedFiles = await uploadSlackDeliveryFiles(delivery, ctx, target);
146
- if (uploadedFiles.length > 0) {
179
+ const response = neutralizeSlackBroadcasts(delivery.response);
180
+ const deliveryFiles = slackDeliveryFiles(delivery.payload);
181
+ if (deliveryFiles.length > 0) {
182
+ const uploadResult = await uploadSlackDeliveryFiles(deliveryFiles, ctx, teamId);
183
+ if (uploadResult.failures.length > 0) {
184
+ throw slackAttachmentFailuresError(uploadResult.failures);
185
+ }
186
+ if (uploadResult.uploaded.length !== deliveryFiles.length) {
187
+ throw new Error(`Slack file delivery prepared ${deliveryFiles.length} files but uploaded ${uploadResult.uploaded.length}.`);
188
+ }
147
189
  const result = await slackApi(ctx, "files.completeUploadExternal", compactJsonObject({
148
190
  channel_id: target.channel,
149
191
  thread_ts: target.threadTs,
150
- initial_comment: delivery.response,
151
- files: uploadedFiles.map((file) => compactJsonObject({
192
+ files: uploadResult.uploaded.map((file) => compactJsonObject({
152
193
  id: stringValue(file.id),
153
194
  title: stringValue(file.title)
154
- }))
155
- }));
195
+ })),
196
+ initial_comment: response || undefined
197
+ }), teamId).catch((error) => {
198
+ throw slackPhaseError("Slack upload completion failed", error);
199
+ });
200
+ const slackFiles = arrayValue(result.files) ?? [];
201
+ const completedFileIds = new Set(slackFiles
202
+ .map((file) => stringValue(recordValue(file)?.id))
203
+ .filter((id) => id !== undefined));
204
+ const missingFileIds = uploadResult.uploaded
205
+ .map((file) => stringValue(file.id))
206
+ .filter((id) => id !== undefined && !completedFileIds.has(id));
207
+ if (missingFileIds.length > 0) {
208
+ throw new Error(`Slack upload completion did not confirm file ids: ${missingFileIds.join(", ")}.`);
209
+ }
156
210
  return compactJsonObject({
157
211
  provider: "slack",
158
212
  mode: "files.completeUploadExternal",
159
213
  channel: target.channel,
160
214
  threadTs: target.threadTs,
161
- uploadedFiles,
162
- slackFiles: arrayValue(result.files),
215
+ messageCount: response ? 1 : 0,
216
+ uploadedFiles: uploadResult.uploaded,
217
+ slackFiles,
163
218
  idempotencyKey: delivery.idempotencyKey
164
219
  });
165
220
  }
166
- const result = await slackApi(ctx, "chat.postMessage", compactJsonObject({
167
- channel: target.channel,
168
- thread_ts: target.threadTs,
169
- text: delivery.response
170
- }));
221
+ const messageResults = await postSlackResponse(response, target.channel, target.threadTs, ctx, teamId);
222
+ return slackTextDeliveryMetadata(target.channel, target.threadTs, delivery.idempotencyKey, messageResults);
223
+ }
224
+ async function postSlackResponse(response, channel, threadTs, ctx, teamId) {
225
+ const markdownChunks = splitSlackMarkdown(response);
226
+ const messageResults = [];
227
+ for (const [index, markdownText] of markdownChunks.entries()) {
228
+ try {
229
+ messageResults.push(await slackApi(ctx, "chat.postMessage", compactJsonObject({
230
+ channel,
231
+ thread_ts: threadTs,
232
+ markdown_text: markdownText
233
+ }), teamId));
234
+ }
235
+ catch (error) {
236
+ throw slackPhaseError(`Slack text delivery failed for message ${index + 1}/${markdownChunks.length}`, error);
237
+ }
238
+ }
239
+ return messageResults;
240
+ }
241
+ function slackMessageTimestamps(messageResults) {
242
+ return messageResults
243
+ .map((result) => stringValue(result.ts))
244
+ .filter((value) => value !== undefined);
245
+ }
246
+ function slackTextDeliveryMetadata(channel, threadTs, idempotencyKey, messageResults) {
171
247
  return compactJsonObject({
172
248
  provider: "slack",
173
- channel: target.channel,
174
- threadTs: target.threadTs,
175
- slackTs: stringValue(result.ts),
176
- idempotencyKey: delivery.idempotencyKey
249
+ mode: "chat.postMessage",
250
+ channel,
251
+ threadTs,
252
+ slackMessageTs: slackMessageTimestamps(messageResults),
253
+ messageCount: messageResults.length,
254
+ idempotencyKey
177
255
  });
178
256
  }
179
257
  /**
@@ -190,12 +268,13 @@ export async function resolveAttachment(attachment, ctx) {
190
268
  return undefined;
191
269
  if (!isSlackUrl(url))
192
270
  throw new Error("Slack attachment download URL host is not allowed.");
193
- return slackAttachmentRequest(url, slackAuthHeaders(ctx.env), stringValue(attachment.filename) ?? stringValue(attachment.name));
271
+ return slackAttachmentRequest(url, slackAuthHeaders(ctx.env, stringValue(attachment.slackTeamId)), stringValue(attachment.filename) ?? stringValue(attachment.name));
194
272
  }
195
273
  async function slackAttachmentDownload(attachment, ctx) {
196
274
  if (!isSlackAttachment(attachment))
197
275
  return undefined;
198
- const headers = slackAuthHeaders(ctx.env);
276
+ const teamId = stringValue(attachment.slackTeamId);
277
+ const headers = slackAuthHeaders(ctx.env, teamId);
199
278
  const remote = recordValue(attachment.remote);
200
279
  const download = recordValue(attachment.download);
201
280
  const directUrl = firstDownloadableUrl([
@@ -216,7 +295,7 @@ async function slackAttachmentDownload(attachment, ctx) {
216
295
  return slackAttachmentRequest(directUrl, headers, filename);
217
296
  }
218
297
  const fileId = slackFileIdFromAttachment(attachment);
219
- if (!fileId || !slackDownloadToken(ctx.env))
298
+ if (!fileId || !slackDownloadToken(ctx.env, teamId))
220
299
  return undefined;
221
300
  const response = await fetchWithPolicy(ctx.fetch, `https://slack.com/api/files.info?file=${encodeURIComponent(fileId)}`, { headers }, {
222
301
  timeoutMs: attachmentFetchTimeoutMs(ctx.env)
@@ -270,12 +349,12 @@ function slackFileIdFromAttachment(attachment) {
270
349
  return id;
271
350
  return undefined;
272
351
  }
273
- function slackAuthHeaders(env) {
274
- const token = slackDownloadToken(env);
352
+ function slackAuthHeaders(env, teamId) {
353
+ const token = slackDownloadToken(env, teamId);
275
354
  return token ? { authorization: `Bearer ${token}` } : {};
276
355
  }
277
- function slackDownloadToken(env) {
278
- return env.SLACK_BOT_TOKEN ?? env.SLACK_USER_TOKEN;
356
+ function slackDownloadToken(env, teamId) {
357
+ return slackWorkspaceCredentials(env, teamId).botToken;
279
358
  }
280
359
  function isSlackUrl(value) {
281
360
  if (!value)
@@ -288,33 +367,59 @@ function isSlackUrl(value) {
288
367
  return false;
289
368
  }
290
369
  }
291
- async function uploadSlackDeliveryFiles(delivery, ctx, _target) {
292
- const files = slackDeliveryFiles(delivery.payload).slice(0, SLACK_DELIVERY_FILE_MAX_COUNT);
370
+ async function uploadSlackDeliveryFiles(files, ctx, teamId) {
293
371
  const uploaded = [];
372
+ const failures = [];
294
373
  for (const file of files) {
295
- const bytes = await deliveryFileBytes(file, ctx);
296
- if (!bytes || bytes.byteLength === 0)
297
- continue;
298
374
  const filename = stringValue(file.filename) ?? stringValue(file.name) ?? filenameFromPath(stringValue(file.path)) ?? "artifact";
375
+ let bytes;
376
+ try {
377
+ bytes = await deliveryFileBytes(file, ctx);
378
+ }
379
+ catch (error) {
380
+ failures.push(slackAttachmentFailure(filename, "read", error));
381
+ continue;
382
+ }
383
+ if (!bytes || bytes.byteLength === 0) {
384
+ failures.push(slackAttachmentFailure(filename, "read", new Error("Slack delivery file was empty or unavailable.")));
385
+ continue;
386
+ }
299
387
  const contentType = stringValue(file.contentType) ?? stringValue(file.mimeType) ?? "application/octet-stream";
300
- const ticket = await slackApi(ctx, "files.getUploadURLExternal", {
301
- filename,
302
- length: bytes.byteLength
303
- });
388
+ let ticket;
389
+ try {
390
+ ticket = await slackApi(ctx, "files.getUploadURLExternal", {
391
+ filename,
392
+ length: bytes.byteLength
393
+ }, teamId, { encoding: "form" });
394
+ }
395
+ catch (error) {
396
+ failures.push(slackAttachmentFailure(filename, "ticket", error));
397
+ continue;
398
+ }
304
399
  const uploadUrl = stringValue(ticket.upload_url);
305
400
  const fileId = stringValue(ticket.file_id);
306
- if (!uploadUrl || !fileId)
307
- throw new Error("Slack file upload URL response was missing upload_url or file_id.");
308
- const response = await fetchWithPolicy(ctx.fetch, uploadUrl, {
309
- method: "POST",
310
- headers: {
311
- "content-type": contentType,
312
- "content-length": String(bytes.byteLength)
313
- },
314
- body: bytes
315
- });
316
- if (!response.ok)
317
- throw new Error(`Slack file upload failed with ${response.status}.`);
401
+ if (!uploadUrl || !fileId) {
402
+ failures.push(slackAttachmentFailure(filename, "ticket", new Error("Slack file upload URL response was missing upload_url or file_id.")));
403
+ continue;
404
+ }
405
+ let response;
406
+ try {
407
+ response = await fetchWithPolicy(ctx.fetch, uploadUrl, {
408
+ method: "POST",
409
+ headers: {
410
+ "content-type": contentType
411
+ },
412
+ body: bytes
413
+ });
414
+ }
415
+ catch (error) {
416
+ failures.push(slackAttachmentFailure(filename, "upload", error));
417
+ continue;
418
+ }
419
+ if (!response.ok) {
420
+ failures.push(slackAttachmentFailure(filename, "upload", new Error(`Slack file upload failed with ${response.status}.`)));
421
+ continue;
422
+ }
318
423
  uploaded.push(compactJsonObject({
319
424
  id: fileId,
320
425
  title: stringValue(file.title) ?? filename,
@@ -325,7 +430,27 @@ async function uploadSlackDeliveryFiles(delivery, ctx, _target) {
325
430
  blobKey: stringValue(file.blobKey)
326
431
  }));
327
432
  }
328
- return uploaded;
433
+ return { uploaded, failures };
434
+ }
435
+ function slackAttachmentFailure(filename, phase, error) {
436
+ return {
437
+ filename,
438
+ phase,
439
+ error: errorDetail(error)
440
+ };
441
+ }
442
+ function slackAttachmentFailuresError(failures) {
443
+ const detail = failures.map((failure) => {
444
+ const filename = stringValue(failure.filename) ?? "artifact";
445
+ const phase = stringValue(failure.phase) ?? "upload";
446
+ const error = stringValue(failure.error) ?? "unknown error";
447
+ return `${filename} (${phase}): ${error}`;
448
+ }).join("; ");
449
+ return new Error(`Slack file delivery failed: ${detail}`);
450
+ }
451
+ function slackPhaseError(message, error) {
452
+ const detail = error instanceof Error ? error.message : String(error);
453
+ return new Error(`${message}: ${detail}`, { cause: error });
329
454
  }
330
455
  function slackDeliveryFiles(payload) {
331
456
  const seen = new Set();
@@ -390,6 +515,8 @@ function slackTurnFromEvent(event, payload, eventId, ctx) {
390
515
  const text = slackMessageText(event);
391
516
  if (!userId || !channelId || !messageTs)
392
517
  return undefined;
518
+ if (stringValue(event.bot_id) || stringValue(recordValue(event.bot_profile)?.id))
519
+ return undefined;
393
520
  const channelType = stringValue(event.channel_type);
394
521
  const surface = slackSurface(event, ctx.env);
395
522
  if (!surface)
@@ -398,6 +525,15 @@ function slackTurnFromEvent(event, payload, eventId, ctx) {
398
525
  const threadRootTs = eventThreadTs ?? messageTs;
399
526
  const deliveryThreadTs = surface === "dm" ? eventThreadTs : threadRootTs;
400
527
  const conversationId = slackConversationId(surface, scope, channelId, threadRootTs, userId);
528
+ const source = slackMessageSource({
529
+ scope,
530
+ channelId,
531
+ threadId: threadRootTs,
532
+ messageId: messageTs,
533
+ authorId: userId,
534
+ channelType,
535
+ observedAt: slackTimestampToIso(messageTs)
536
+ });
401
537
  const delivery = compactJsonObject({
402
538
  provider: "slack",
403
539
  channel: channelId,
@@ -408,7 +544,7 @@ function slackTurnFromEvent(event, payload, eventId, ctx) {
408
544
  eventId,
409
545
  surface
410
546
  });
411
- const principal = slackPrincipal(userId, ctx.channel.name, teamId, enterpriseId);
547
+ const principal = slackPrincipal(userId, teamId, enterpriseId);
412
548
  return {
413
549
  eventId,
414
550
  channel: ctx.channel.name,
@@ -417,10 +553,11 @@ function slackTurnFromEvent(event, payload, eventId, ctx) {
417
553
  principal,
418
554
  initiator: principal,
419
555
  message: text,
420
- attachments: slackAttachments(event),
556
+ attachments: slackAttachments(event, teamId),
421
557
  delivery,
422
558
  metadata: {
423
559
  provider: "slack",
560
+ source,
424
561
  slack: compactJsonObject({
425
562
  eventId,
426
563
  eventType,
@@ -436,7 +573,74 @@ function slackTurnFromEvent(event, payload, eventId, ctx) {
436
573
  }
437
574
  };
438
575
  }
439
- function slackPrincipal(userId, issuer, teamId, enterpriseId) {
576
+ function slackObservationFromEvent(event, payload, eventId, ctx) {
577
+ if (stringValue(event.type) !== "message")
578
+ return undefined;
579
+ const channelType = stringValue(event.channel_type) ?? "channel";
580
+ if (channelType === "im")
581
+ return undefined;
582
+ const subtype = stringValue(event.subtype);
583
+ const changedMessage = subtype === "message_changed" ? recordValue(event.message) : undefined;
584
+ const previousMessage = recordValue(event.previous_message);
585
+ const messageEvent = changedMessage ?? previousMessage ?? event;
586
+ const channelId = stringValue(event.channel) ?? stringValue(messageEvent.channel);
587
+ const messageTs = subtype === "message_deleted"
588
+ ? stringValue(event.deleted_ts) ?? stringValue(previousMessage?.ts)
589
+ : stringValue(messageEvent.ts) ?? stringValue(event.ts);
590
+ const userId = stringValue(messageEvent.user) ?? stringValue(previousMessage?.user) ?? stringValue(event.user) ??
591
+ stringValue(messageEvent.bot_id) ?? stringValue(previousMessage?.bot_id) ?? stringValue(event.bot_id);
592
+ if (!channelId || !messageTs)
593
+ return undefined;
594
+ const teamId = slackTeamId(event, payload);
595
+ const enterpriseId = slackEnterpriseId(event, payload);
596
+ const scope = enterpriseId ?? teamId ?? "unknown";
597
+ const threadRootTs = stringValue(messageEvent.thread_ts) ?? stringValue(previousMessage?.thread_ts) ?? messageTs;
598
+ const edited = recordValue(messageEvent.edited);
599
+ const deletedAt = subtype === "message_deleted"
600
+ ? slackTimestampToIso(stringValue(event.event_ts) ?? messageTs)
601
+ : undefined;
602
+ const source = slackMessageSource({
603
+ scope,
604
+ channelId,
605
+ threadId: threadRootTs,
606
+ messageId: messageTs,
607
+ authorId: userId,
608
+ channelType,
609
+ observedAt: slackTimestampToIso(stringValue(event.event_ts) ?? messageTs),
610
+ editedAt: slackTimestampToIso(stringValue(edited?.ts)),
611
+ deletedAt
612
+ });
613
+ const observation = {
614
+ eventId,
615
+ conversationId: slackConversationId("channel", scope, channelId, threadRootTs, userId ?? "unknown"),
616
+ messageId: externalSlackMessageId(scope, channelId, messageTs),
617
+ message: deletedAt ? "" : slackMessageText(messageEvent),
618
+ source,
619
+ attachments: deletedAt ? [] : slackAttachments(messageEvent, teamId),
620
+ metadata: {
621
+ provider: "slack",
622
+ source,
623
+ slack: compactJsonObject({
624
+ eventId,
625
+ eventType: "message",
626
+ subtype,
627
+ teamId,
628
+ enterpriseId,
629
+ channel: channelId,
630
+ channelType,
631
+ ts: messageTs,
632
+ threadTs: threadRootTs,
633
+ userId,
634
+ surface: "channel"
635
+ })
636
+ }
637
+ };
638
+ const createdAt = slackTimestampToIso(messageTs);
639
+ if (createdAt)
640
+ observation.createdAt = createdAt;
641
+ return observation;
642
+ }
643
+ function slackPrincipal(userId, teamId, enterpriseId) {
440
644
  const attributes = {
441
645
  provider: "slack",
442
646
  slackUserId: userId
@@ -448,7 +652,7 @@ function slackPrincipal(userId, issuer, teamId, enterpriseId) {
448
652
  return {
449
653
  type: "user",
450
654
  id: userId,
451
- issuer,
655
+ issuer: `slack:${enterpriseId ?? teamId ?? "unknown"}`,
452
656
  attributes
453
657
  };
454
658
  }
@@ -464,110 +668,63 @@ function slackSurface(event, env) {
464
668
  return "assistant";
465
669
  return "dm";
466
670
  }
467
- function slackConversationId(surface, scope, channelId, threadTs, userId) {
468
- if (surface === "channel")
469
- return `slack:${scope}:channel:${channelId}:thread:${threadTs}`;
470
- if (surface === "assistant")
471
- return `slack:${scope}:assistant:${channelId}:thread:${threadTs}`;
472
- return `slack:${scope}:dm:${channelId}:user:${userId}`;
473
- }
474
- function ignoredEventReason(event, env) {
671
+ function ignoredEventReason(event, payload, env, teamId) {
475
672
  const eventType = stringValue(event.type);
476
- if (eventType === "assistant_thread_started" || eventType === "assistant_thread_context_changed")
673
+ if (eventType === "app_home_opened" || eventType === "app_context_changed")
477
674
  return eventType;
478
675
  if (eventType !== "app_mention" && eventType !== "message")
479
676
  return undefined;
480
677
  const subtype = stringValue(event.subtype);
481
- if (subtype && subtype !== "file_share")
678
+ if (subtype && !["file_share", "bot_message", "message_changed", "message_deleted"].includes(subtype))
482
679
  return `message_subtype:${subtype}`;
483
- if (stringValue(event.bot_id))
484
- return "bot_message";
485
- const botUserId = env.SLACK_BOT_USER_ID;
486
- if (botUserId && stringValue(event.user) === botUserId)
680
+ const nestedMessage = recordValue(event.message) ?? recordValue(event.previous_message);
681
+ const botUserId = slackWorkspaceCredentials(env, teamId).botUserId;
682
+ if (botUserId && (stringValue(event.user) === botUserId || stringValue(nestedMessage?.user) === botUserId))
683
+ return "self_message";
684
+ const botProfile = recordValue(event.bot_profile) ?? recordValue(nestedMessage?.bot_profile);
685
+ const eventAppId = stringValue(event.app_id) ?? stringValue(nestedMessage?.app_id) ?? stringValue(botProfile?.app_id);
686
+ if (eventAppId && eventAppId === stringValue(payload.api_app_id))
487
687
  return "self_message";
688
+ const channelType = stringValue(event.channel_type);
689
+ if (channelType === "im" && (subtype === "message_changed" || subtype === "message_deleted")) {
690
+ return `message_subtype:${subtype}`;
691
+ }
692
+ if (channelType === "im" && (subtype === "bot_message" || stringValue(event.bot_id) || stringValue(nestedMessage?.bot_id)))
693
+ return "bot_message";
488
694
  return undefined;
489
695
  }
490
696
  function ignored(reason, metadata = {}) {
491
697
  return { kind: "ignored", status: 200, body: { ok: true, ignored: true, reason, ...metadata } };
492
698
  }
493
- async function summarizeConversation(ctx, conversation) {
494
- const messages = await ctx.state.listRecentMessages(conversation.id, 8);
495
- const latestUser = latestText(messages, "user");
496
- const latestAssistant = latestText(messages, "assistant");
497
- const slack = recordValue(conversation.metadata.slack);
498
- return compactJsonObject({
499
- conversationId: conversation.id,
500
- updatedAt: conversation.updatedAt,
501
- surface: stringValue(slack?.surface),
502
- channelId: stringValue(slack?.channel),
503
- threadTs: stringValue(slack?.threadTs),
504
- latestUser,
505
- latestAssistant
506
- });
507
- }
508
- function latestText(messages, role) {
509
- const message = [...messages].reverse().find((candidate) => candidate.role === role);
510
- const text = typeof message?.content.text === "string" ? message.content.text : undefined;
511
- return text ? truncate(text, SLACK_SUMMARY_MAX_CHARS) : undefined;
512
- }
513
- function messageHistoryLine(message) {
514
- const text = typeof message.content.text === "string" ? message.content.text : JSON.stringify(message.content);
515
- return `${message.role}: ${text}`;
516
- }
517
699
  function slackDeliveryTarget(turn, payload) {
518
700
  const target = recordValue(turn?.delivery) ?? recordValue(payload?.delivery);
519
701
  const out = {};
520
702
  const channel = stringValue(target?.channel);
521
703
  const threadTs = stringValue(target?.threadTs) ?? stringValue(target?.thread_ts);
704
+ const messageTs = stringValue(target?.ts);
522
705
  if (channel)
523
706
  out.channel = channel;
524
707
  if (threadTs)
525
708
  out.threadTs = threadTs;
709
+ if (messageTs)
710
+ out.messageTs = messageTs;
526
711
  return out;
527
712
  }
528
- async function slackApi(ctx, method, body) {
529
- const token = ctx.env.SLACK_BOT_TOKEN;
530
- if (!token)
531
- throw new Error("Slack API call requires SLACK_BOT_TOKEN.");
532
- // fetchWithPolicy adds per-attempt timeouts and honors Slack's 429 Retry-After header before retrying.
533
- const response = await fetchWithPolicy(ctx.fetch, `https://slack.com/api/${method}`, {
534
- method: "POST",
535
- headers: {
536
- authorization: `Bearer ${token}`,
537
- "content-type": "application/json; charset=utf-8"
538
- },
539
- body: JSON.stringify(body)
540
- });
541
- const json = await response.json().catch(() => ({}));
542
- const result = recordValue(json) ?? {};
543
- if (!response.ok || result.ok === false) {
544
- const error = stringValue(result.error) ?? `Slack API ${method} failed with ${response.status}`;
545
- throw new Error(error);
546
- }
547
- return result;
548
- }
549
- function slackMessageText(event) {
550
- return stringValue(event.text)?.trim() ?? "";
713
+ function slackTeamIdForTurn(turn, payload) {
714
+ const metadata = recordValue(turn?.metadata?.slack);
715
+ const delivery = recordValue(turn?.delivery) ?? recordValue(payload?.delivery);
716
+ return stringValue(metadata?.teamId) ?? stringValue(delivery?.teamId) ?? stringValue(payload?.teamId);
551
717
  }
552
- function slackAttachments(event) {
553
- const files = Array.isArray(event.files) ? event.files.filter(isJsonObject) : [];
554
- return files.map((file) => compactJsonObject({
555
- provider: "slack",
556
- fileId: stringValue(file.id),
557
- slackFileId: stringValue(file.id),
558
- filename: stringValue(file.name) ?? stringValue(file.title),
559
- title: stringValue(file.title),
560
- contentType: stringValue(file.mimetype),
561
- size: numberValue(file.size),
562
- url: stringValue(file.url_private_download) ?? stringValue(file.url_private),
563
- remote: compactJsonObject({
564
- provider: "slack",
565
- auth: "slack",
566
- trusted: true,
567
- fileId: stringValue(file.id),
568
- url: stringValue(file.url_private_download) ?? stringValue(file.url_private)
569
- })
570
- }));
718
+ const SLACK_BROADCAST_PATTERN = /<!(here|channel|everyone)(?:\|[^>]*)?>/giu;
719
+ /**
720
+ * Neutralize broadcast commands in outbound text. Inbound `<!here>` /
721
+ * `<!channel>` / `<!everyone>` pass through to the agent verbatim, so an
722
+ * agent that quotes user input could otherwise ping the whole channel.
723
+ * The plain-text forms (`@here`, …) render inert in a chat.postMessage
724
+ * `text` field; links and user mentions are left untouched.
725
+ */
726
+ function neutralizeSlackBroadcasts(text) {
727
+ return text.replace(SLACK_BROADCAST_PATTERN, (_match, name) => `@${name.toLowerCase()}`);
571
728
  }
572
729
  function slackTeamId(event, payload) {
573
730
  return stringValue(event.team) ??
@@ -609,9 +766,6 @@ function isJsonObject(value) {
609
766
  function stringValue(value) {
610
767
  return typeof value === "string" && value.length > 0 ? value : undefined;
611
768
  }
612
- function numberValue(value) {
613
- return typeof value === "number" && Number.isFinite(value) ? value : undefined;
614
- }
615
769
  function arrayValue(value) {
616
770
  return Array.isArray(value) ? value.filter(isJsonValue) : undefined;
617
771
  }
@@ -630,9 +784,4 @@ function isTruthy(value) {
630
784
  function filenameFromPath(path) {
631
785
  return path?.split("/").filter(Boolean).at(-1);
632
786
  }
633
- function truncate(value, maxChars) {
634
- if (value.length <= maxChars)
635
- return value;
636
- return `${value.slice(0, Math.max(0, maxChars - 1))}...`;
637
- }
638
787
  //# sourceMappingURL=index.js.map