@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/LICENSE +1 -1
- package/README.md +133 -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 +12 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +372 -203
- 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,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
|
-
|
|
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
|
|
45
|
-
|
|
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,
|
|
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 (
|
|
68
|
-
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
|
+
};
|
|
69
127
|
}
|
|
70
128
|
return {
|
|
71
|
-
|
|
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
|
-
|
|
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
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
145
|
-
|
|
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
|
-
|
|
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
|
-
|
|
161
|
-
|
|
215
|
+
messageCount: response ? 1 : 0,
|
|
216
|
+
uploadedFiles: uploadResult.uploaded,
|
|
217
|
+
slackFiles,
|
|
162
218
|
idempotencyKey: delivery.idempotencyKey
|
|
163
219
|
});
|
|
164
220
|
}
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
|
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 === "
|
|
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 &&
|
|
678
|
+
if (subtype && !["file_share", "bot_message", "message_changed", "message_deleted"].includes(subtype))
|
|
462
679
|
return `message_subtype:${subtype}`;
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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
|
-
|
|
509
|
-
const
|
|
510
|
-
|
|
511
|
-
|
|
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
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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
|