@assemblyline-agents/slack 1.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,14 +54,20 @@ 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)
33
66
  definition.connection = options.connection;
34
67
  return Object.assign(defineChannel(definition), {
35
68
  normalizeHttp,
69
+ ...(options.resolvePrincipal ? { resolvePrincipal: options.resolvePrincipal } : {}),
70
+ isPrivateSurface: options.isPrivateSurface ?? defaultSlackPrivateSurface,
36
71
  startTurn,
37
72
  augmentContext,
38
73
  send,
@@ -40,14 +75,21 @@ export function defineSlackChannel(options = {}) {
40
75
  ingressAuth: { requiredSecretEnv: SLACK_INGRESS_SECRET_ENV }
41
76
  });
42
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
+ }
43
84
  export function normalizeHttp(request, ctx) {
44
- const signingSecret = ctx.env.SLACK_SIGNING_SECRET;
45
- 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)
46
89
  return { kind: "response", status: 500, body: { ok: false, error: "missing_slack_signing_secret" } };
47
- if (!verifySlackRequest(request, signingSecret)) {
90
+ if (!signingSecrets.some((secret) => verifySlackRequest(request, secret))) {
48
91
  return { kind: "response", status: 401, body: { ok: false, error: "invalid_slack_signature" } };
49
92
  }
50
- const payload = request.body;
51
93
  if (payload.type === "url_verification") {
52
94
  return { kind: "response", status: 200, body: { challenge: stringValue(payload.challenge) ?? "" } };
53
95
  }
@@ -60,119 +102,156 @@ export function normalizeHttp(request, ctx) {
60
102
  const event = recordValue(payload.event);
61
103
  if (!event)
62
104
  return ignored("missing_event", { eventId });
63
- const ignoredReason = ignoredEventReason(event, ctx.env);
105
+ const ignoredReason = ignoredEventReason(event, payload, ctx.env, teamId);
64
106
  if (ignoredReason)
65
107
  return ignored(ignoredReason, { eventId, eventType: stringValue(event.type) ?? null });
66
108
  const normalized = slackTurnFromEvent(event, payload, eventId, ctx);
67
- if (!normalized) {
68
- 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
+ };
69
127
  }
70
128
  return {
71
- kind: "accepted",
72
- status: 200,
73
- body: { ok: true, accepted: true },
74
- idempotencyKey: eventId,
75
- idempotencyScope: `channel:${ctx.channel.name}:slack`,
76
- turn: normalized
129
+ ...ignored("unsupported_event", { eventId, eventType: stringValue(event.type) ?? null })
77
130
  };
78
131
  }
79
132
  export async function startTurn(turn, ctx) {
80
- if (!isTruthy(ctx.env.SLACK_ASSISTANT_ENABLED))
81
- return undefined;
82
133
  const target = slackDeliveryTarget(turn);
83
- 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)
84
137
  return undefined;
85
138
  const channel = target.channel;
86
- const threadTs = target.threadTs;
87
- await slackApi(ctx, "assistant.threads.setStatus", {
88
- channel_id: channel,
89
- thread_ts: threadTs,
90
- status: "Working on it..."
91
- }).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?.();
92
159
  return {
93
160
  stop: async () => {
161
+ if (stopped)
162
+ return;
163
+ stopped = true;
164
+ clearInterval(refreshTimer);
165
+ await refreshInFlight;
94
166
  await slackApi(ctx, "assistant.threads.setStatus", {
95
167
  channel_id: channel,
96
168
  thread_ts: threadTs,
97
169
  status: ""
98
- }).catch(() => undefined);
170
+ }, teamId).catch(() => undefined);
99
171
  }
100
172
  };
101
173
  }
102
- export async function augmentContext(turn, ctx) {
103
- const recentHistory = (await ctx.state.listRecentMessages(turn.conversationId, SLACK_RECENT_HISTORY_LIMIT)).map(messageHistoryLine);
104
- const since = new Date(Date.now() - SLACK_RECENT_ACTIVITY_HOURS * 60 * 60 * 1000).toISOString();
105
- const relatedConversations = turn.userId && ctx.state.listRecentConversationsBySubject
106
- ? await ctx.state.listRecentConversationsBySubject({
107
- channel: turn.channel,
108
- subject: turn.userId,
109
- since,
110
- limit: SLACK_RECENT_ACTIVITY_LIMIT,
111
- excludeId: turn.conversationId
112
- })
113
- : [];
114
- const related = await Promise.all(relatedConversations.map((conversation) => summarizeConversation(ctx, conversation)));
115
- const slack = recordValue(turn.metadata?.slack);
116
- return {
117
- recentHistory,
118
- channelContext: compactJsonObject({
119
- provider: "slack",
120
- surface: stringValue(slack?.surface),
121
- teamId: stringValue(slack?.teamId),
122
- enterpriseId: stringValue(slack?.enterpriseId),
123
- channelId: stringValue(slack?.channel),
124
- threadTs: stringValue(slack?.threadTs),
125
- currentConversation: {
126
- id: turn.conversationId,
127
- userId: turn.userId ?? null,
128
- eventId: turn.eventId
129
- },
130
- relatedConversations: related,
131
- contextPolicy: {
132
- source: "openeve_state",
133
- maxRelatedConversations: SLACK_RECENT_ACTIVITY_LIMIT,
134
- maxAgeHours: SLACK_RECENT_ACTIVITY_HOURS,
135
- note: "Related Slack context is bounded to this user's recent OpenEve conversations and contains summaries, not workspace-wide Slack history."
136
- }
137
- })
138
- };
139
- }
140
174
  export async function send(delivery, ctx) {
141
175
  const target = slackDeliveryTarget(delivery.turn, delivery.payload);
176
+ const teamId = slackTeamIdForTurn(delivery.turn, delivery.payload);
142
177
  if (!target.channel)
143
178
  throw new Error("Slack delivery requires a channel.");
144
- const uploadedFiles = await uploadSlackDeliveryFiles(delivery, ctx, target);
145
- 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
+ }
146
189
  const result = await slackApi(ctx, "files.completeUploadExternal", compactJsonObject({
147
190
  channel_id: target.channel,
148
191
  thread_ts: target.threadTs,
149
- initial_comment: delivery.response,
150
- files: uploadedFiles.map((file) => compactJsonObject({
192
+ files: uploadResult.uploaded.map((file) => compactJsonObject({
151
193
  id: stringValue(file.id),
152
194
  title: stringValue(file.title)
153
- }))
154
- }));
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
+ }
155
210
  return compactJsonObject({
156
211
  provider: "slack",
157
212
  mode: "files.completeUploadExternal",
158
213
  channel: target.channel,
159
214
  threadTs: target.threadTs,
160
- uploadedFiles,
161
- slackFiles: arrayValue(result.files),
215
+ messageCount: response ? 1 : 0,
216
+ uploadedFiles: uploadResult.uploaded,
217
+ slackFiles,
162
218
  idempotencyKey: delivery.idempotencyKey
163
219
  });
164
220
  }
165
- const result = await slackApi(ctx, "chat.postMessage", compactJsonObject({
166
- channel: target.channel,
167
- thread_ts: target.threadTs,
168
- text: delivery.response
169
- }));
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) {
170
247
  return compactJsonObject({
171
248
  provider: "slack",
172
- channel: target.channel,
173
- threadTs: target.threadTs,
174
- slackTs: stringValue(result.ts),
175
- idempotencyKey: delivery.idempotencyKey
249
+ mode: "chat.postMessage",
250
+ channel,
251
+ threadTs,
252
+ slackMessageTs: slackMessageTimestamps(messageResults),
253
+ messageCount: messageResults.length,
254
+ idempotencyKey
176
255
  });
177
256
  }
178
257
  /**
@@ -189,12 +268,13 @@ export async function resolveAttachment(attachment, ctx) {
189
268
  return undefined;
190
269
  if (!isSlackUrl(url))
191
270
  throw new Error("Slack attachment download URL host is not allowed.");
192
- 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));
193
272
  }
194
273
  async function slackAttachmentDownload(attachment, ctx) {
195
274
  if (!isSlackAttachment(attachment))
196
275
  return undefined;
197
- const headers = slackAuthHeaders(ctx.env);
276
+ const teamId = stringValue(attachment.slackTeamId);
277
+ const headers = slackAuthHeaders(ctx.env, teamId);
198
278
  const remote = recordValue(attachment.remote);
199
279
  const download = recordValue(attachment.download);
200
280
  const directUrl = firstDownloadableUrl([
@@ -215,7 +295,7 @@ async function slackAttachmentDownload(attachment, ctx) {
215
295
  return slackAttachmentRequest(directUrl, headers, filename);
216
296
  }
217
297
  const fileId = slackFileIdFromAttachment(attachment);
218
- if (!fileId || !slackDownloadToken(ctx.env))
298
+ if (!fileId || !slackDownloadToken(ctx.env, teamId))
219
299
  return undefined;
220
300
  const response = await fetchWithPolicy(ctx.fetch, `https://slack.com/api/files.info?file=${encodeURIComponent(fileId)}`, { headers }, {
221
301
  timeoutMs: attachmentFetchTimeoutMs(ctx.env)
@@ -269,12 +349,12 @@ function slackFileIdFromAttachment(attachment) {
269
349
  return id;
270
350
  return undefined;
271
351
  }
272
- function slackAuthHeaders(env) {
273
- const token = slackDownloadToken(env);
352
+ function slackAuthHeaders(env, teamId) {
353
+ const token = slackDownloadToken(env, teamId);
274
354
  return token ? { authorization: `Bearer ${token}` } : {};
275
355
  }
276
- function slackDownloadToken(env) {
277
- return env.SLACK_BOT_TOKEN ?? env.SLACK_USER_TOKEN;
356
+ function slackDownloadToken(env, teamId) {
357
+ return slackWorkspaceCredentials(env, teamId).botToken;
278
358
  }
279
359
  function isSlackUrl(value) {
280
360
  if (!value)
@@ -287,33 +367,59 @@ function isSlackUrl(value) {
287
367
  return false;
288
368
  }
289
369
  }
290
- async function uploadSlackDeliveryFiles(delivery, ctx, _target) {
291
- const files = slackDeliveryFiles(delivery.payload).slice(0, SLACK_DELIVERY_FILE_MAX_COUNT);
370
+ async function uploadSlackDeliveryFiles(files, ctx, teamId) {
292
371
  const uploaded = [];
372
+ const failures = [];
293
373
  for (const file of files) {
294
- const bytes = await deliveryFileBytes(file, ctx);
295
- if (!bytes || bytes.byteLength === 0)
296
- continue;
297
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
+ }
298
387
  const contentType = stringValue(file.contentType) ?? stringValue(file.mimeType) ?? "application/octet-stream";
299
- const ticket = await slackApi(ctx, "files.getUploadURLExternal", {
300
- filename,
301
- length: bytes.byteLength
302
- });
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
+ }
303
399
  const uploadUrl = stringValue(ticket.upload_url);
304
400
  const fileId = stringValue(ticket.file_id);
305
- if (!uploadUrl || !fileId)
306
- throw new Error("Slack file upload URL response was missing upload_url or file_id.");
307
- const response = await fetchWithPolicy(ctx.fetch, uploadUrl, {
308
- method: "POST",
309
- headers: {
310
- "content-type": contentType,
311
- "content-length": String(bytes.byteLength)
312
- },
313
- body: bytes
314
- });
315
- if (!response.ok)
316
- 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
+ }
317
423
  uploaded.push(compactJsonObject({
318
424
  id: fileId,
319
425
  title: stringValue(file.title) ?? filename,
@@ -324,7 +430,27 @@ async function uploadSlackDeliveryFiles(delivery, ctx, _target) {
324
430
  blobKey: stringValue(file.blobKey)
325
431
  }));
326
432
  }
327
- 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 });
328
454
  }
329
455
  function slackDeliveryFiles(payload) {
330
456
  const seen = new Set();
@@ -389,6 +515,8 @@ function slackTurnFromEvent(event, payload, eventId, ctx) {
389
515
  const text = slackMessageText(event);
390
516
  if (!userId || !channelId || !messageTs)
391
517
  return undefined;
518
+ if (stringValue(event.bot_id) || stringValue(recordValue(event.bot_profile)?.id))
519
+ return undefined;
392
520
  const channelType = stringValue(event.channel_type);
393
521
  const surface = slackSurface(event, ctx.env);
394
522
  if (!surface)
@@ -397,6 +525,15 @@ function slackTurnFromEvent(event, payload, eventId, ctx) {
397
525
  const threadRootTs = eventThreadTs ?? messageTs;
398
526
  const deliveryThreadTs = surface === "dm" ? eventThreadTs : threadRootTs;
399
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
+ });
400
537
  const delivery = compactJsonObject({
401
538
  provider: "slack",
402
539
  channel: channelId,
@@ -407,16 +544,20 @@ function slackTurnFromEvent(event, payload, eventId, ctx) {
407
544
  eventId,
408
545
  surface
409
546
  });
547
+ const principal = slackPrincipal(userId, teamId, enterpriseId);
410
548
  return {
411
549
  eventId,
412
550
  channel: ctx.channel.name,
413
551
  conversationId,
414
552
  userId,
553
+ principal,
554
+ initiator: principal,
415
555
  message: text,
416
- attachments: slackAttachments(event),
556
+ attachments: slackAttachments(event, teamId),
417
557
  delivery,
418
558
  metadata: {
419
559
  provider: "slack",
560
+ source,
420
561
  slack: compactJsonObject({
421
562
  eventId,
422
563
  eventType,
@@ -432,6 +573,89 @@ function slackTurnFromEvent(event, payload, eventId, ctx) {
432
573
  }
433
574
  };
434
575
  }
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) {
644
+ const attributes = {
645
+ provider: "slack",
646
+ slackUserId: userId
647
+ };
648
+ if (teamId)
649
+ attributes.slackTeamId = teamId;
650
+ if (enterpriseId)
651
+ attributes.slackEnterpriseId = enterpriseId;
652
+ return {
653
+ type: "user",
654
+ id: userId,
655
+ issuer: `slack:${enterpriseId ?? teamId ?? "unknown"}`,
656
+ attributes
657
+ };
658
+ }
435
659
  function slackSurface(event, env) {
436
660
  const eventType = stringValue(event.type);
437
661
  if (eventType === "app_mention")
@@ -444,110 +668,63 @@ function slackSurface(event, env) {
444
668
  return "assistant";
445
669
  return "dm";
446
670
  }
447
- function slackConversationId(surface, scope, channelId, threadTs, userId) {
448
- if (surface === "channel")
449
- return `slack:${scope}:channel:${channelId}:thread:${threadTs}`;
450
- if (surface === "assistant")
451
- return `slack:${scope}:assistant:${channelId}:thread:${threadTs}`;
452
- return `slack:${scope}:dm:${channelId}:user:${userId}`;
453
- }
454
- function ignoredEventReason(event, env) {
671
+ function ignoredEventReason(event, payload, env, teamId) {
455
672
  const eventType = stringValue(event.type);
456
- if (eventType === "assistant_thread_started" || eventType === "assistant_thread_context_changed")
673
+ if (eventType === "app_home_opened" || eventType === "app_context_changed")
457
674
  return eventType;
458
675
  if (eventType !== "app_mention" && eventType !== "message")
459
676
  return undefined;
460
677
  const subtype = stringValue(event.subtype);
461
- if (subtype && subtype !== "file_share")
678
+ if (subtype && !["file_share", "bot_message", "message_changed", "message_deleted"].includes(subtype))
462
679
  return `message_subtype:${subtype}`;
463
- if (stringValue(event.bot_id))
464
- return "bot_message";
465
- const botUserId = env.SLACK_BOT_USER_ID;
466
- 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))
467
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";
468
694
  return undefined;
469
695
  }
470
696
  function ignored(reason, metadata = {}) {
471
697
  return { kind: "ignored", status: 200, body: { ok: true, ignored: true, reason, ...metadata } };
472
698
  }
473
- async function summarizeConversation(ctx, conversation) {
474
- const messages = await ctx.state.listRecentMessages(conversation.id, 8);
475
- const latestUser = latestText(messages, "user");
476
- const latestAssistant = latestText(messages, "assistant");
477
- const slack = recordValue(conversation.metadata.slack);
478
- return compactJsonObject({
479
- conversationId: conversation.id,
480
- updatedAt: conversation.updatedAt,
481
- surface: stringValue(slack?.surface),
482
- channelId: stringValue(slack?.channel),
483
- threadTs: stringValue(slack?.threadTs),
484
- latestUser,
485
- latestAssistant
486
- });
487
- }
488
- function latestText(messages, role) {
489
- const message = [...messages].reverse().find((candidate) => candidate.role === role);
490
- const text = typeof message?.content.text === "string" ? message.content.text : undefined;
491
- return text ? truncate(text, SLACK_SUMMARY_MAX_CHARS) : undefined;
492
- }
493
- function messageHistoryLine(message) {
494
- const text = typeof message.content.text === "string" ? message.content.text : JSON.stringify(message.content);
495
- return `${message.role}: ${text}`;
496
- }
497
699
  function slackDeliveryTarget(turn, payload) {
498
700
  const target = recordValue(turn?.delivery) ?? recordValue(payload?.delivery);
499
701
  const out = {};
500
702
  const channel = stringValue(target?.channel);
501
703
  const threadTs = stringValue(target?.threadTs) ?? stringValue(target?.thread_ts);
704
+ const messageTs = stringValue(target?.ts);
502
705
  if (channel)
503
706
  out.channel = channel;
504
707
  if (threadTs)
505
708
  out.threadTs = threadTs;
709
+ if (messageTs)
710
+ out.messageTs = messageTs;
506
711
  return out;
507
712
  }
508
- async function slackApi(ctx, method, body) {
509
- const token = ctx.env.SLACK_BOT_TOKEN;
510
- if (!token)
511
- throw new Error("Slack API call requires SLACK_BOT_TOKEN.");
512
- // fetchWithPolicy adds per-attempt timeouts and honors Slack's 429 Retry-After header before retrying.
513
- const response = await fetchWithPolicy(ctx.fetch, `https://slack.com/api/${method}`, {
514
- method: "POST",
515
- headers: {
516
- authorization: `Bearer ${token}`,
517
- "content-type": "application/json; charset=utf-8"
518
- },
519
- body: JSON.stringify(body)
520
- });
521
- const json = await response.json().catch(() => ({}));
522
- const result = recordValue(json) ?? {};
523
- if (!response.ok || result.ok === false) {
524
- const error = stringValue(result.error) ?? `Slack API ${method} failed with ${response.status}`;
525
- throw new Error(error);
526
- }
527
- return result;
528
- }
529
- function slackMessageText(event) {
530
- 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);
531
717
  }
532
- function slackAttachments(event) {
533
- const files = Array.isArray(event.files) ? event.files.filter(isJsonObject) : [];
534
- return files.map((file) => compactJsonObject({
535
- provider: "slack",
536
- fileId: stringValue(file.id),
537
- slackFileId: stringValue(file.id),
538
- filename: stringValue(file.name) ?? stringValue(file.title),
539
- title: stringValue(file.title),
540
- contentType: stringValue(file.mimetype),
541
- size: numberValue(file.size),
542
- url: stringValue(file.url_private_download) ?? stringValue(file.url_private),
543
- remote: compactJsonObject({
544
- provider: "slack",
545
- auth: "slack",
546
- trusted: true,
547
- fileId: stringValue(file.id),
548
- url: stringValue(file.url_private_download) ?? stringValue(file.url_private)
549
- })
550
- }));
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()}`);
551
728
  }
552
729
  function slackTeamId(event, payload) {
553
730
  return stringValue(event.team) ??
@@ -589,9 +766,6 @@ function isJsonObject(value) {
589
766
  function stringValue(value) {
590
767
  return typeof value === "string" && value.length > 0 ? value : undefined;
591
768
  }
592
- function numberValue(value) {
593
- return typeof value === "number" && Number.isFinite(value) ? value : undefined;
594
- }
595
769
  function arrayValue(value) {
596
770
  return Array.isArray(value) ? value.filter(isJsonValue) : undefined;
597
771
  }
@@ -610,9 +784,4 @@ function isTruthy(value) {
610
784
  function filenameFromPath(path) {
611
785
  return path?.split("/").filter(Boolean).at(-1);
612
786
  }
613
- function truncate(value, maxChars) {
614
- if (value.length <= maxChars)
615
- return value;
616
- return `${value.slice(0, Math.max(0, maxChars - 1))}...`;
617
- }
618
787
  //# sourceMappingURL=index.js.map