@assemblyline-agents/slack 2.0.0 → 3.1.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/LICENSE +1 -1
- package/README.md +114 -1
- package/dist/context.d.ts +7 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +236 -0
- package/dist/context.js.map +1 -0
- package/dist/error-detail.d.ts +3 -0
- package/dist/error-detail.d.ts.map +1 -0
- package/dist/error-detail.js +27 -0
- package/dist/error-detail.js.map +1 -0
- package/dist/index.d.ts +9 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +355 -206
- package/dist/index.js.map +1 -1
- package/dist/message-context.d.ts +26 -0
- package/dist/message-context.d.ts.map +1 -0
- package/dist/message-context.js +165 -0
- package/dist/message-context.js.map +1 -0
- package/dist/outbound-markdown.d.ts +8 -0
- package/dist/outbound-markdown.d.ts.map +1 -0
- package/dist/outbound-markdown.js +30 -0
- package/dist/outbound-markdown.js.map +1 -0
- package/dist/slack-api.d.ts +6 -0
- package/dist/slack-api.d.ts.map +1 -0
- package/dist/slack-api.js +40 -0
- package/dist/slack-api.js.map +1 -0
- package/dist/workspace-credentials.d.ts +11 -0
- package/dist/workspace-credentials.d.ts.map +1 -0
- package/dist/workspace-credentials.js +57 -0
- package/dist/workspace-credentials.js.map +1 -0
- package/package.json +4 -4
- package/templates/slack-app-manifest.json +47 -0
- package/skills/slack/SKILL.md +0 -8
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
|
|
16
|
-
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
const
|
|
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
|
-
|
|
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
|
|
46
|
-
|
|
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,
|
|
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 (
|
|
69
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
|
146
|
-
|
|
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
|
-
|
|
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
|
-
|
|
162
|
-
|
|
215
|
+
messageCount: response ? 1 : 0,
|
|
216
|
+
uploadedFiles: uploadResult.uploaded,
|
|
217
|
+
slackFiles,
|
|
163
218
|
idempotencyKey: delivery.idempotencyKey
|
|
164
219
|
});
|
|
165
220
|
}
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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,
|
|
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
|
|
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
|
|
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 === "
|
|
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 &&
|
|
678
|
+
if (subtype && !["file_share", "bot_message", "message_changed", "message_deleted"].includes(subtype))
|
|
482
679
|
return `message_subtype:${subtype}`;
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
-
|
|
529
|
-
const
|
|
530
|
-
|
|
531
|
-
|
|
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
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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
|