@opentag/slack 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/dispatcher-events.d.ts +5 -0
- package/dist/dispatcher-events.d.ts.map +1 -1
- package/dist/events.d.ts +98 -1
- package/dist/events.d.ts.map +1 -1
- package/dist/index.js +1255 -197
- package/dist/index.js.map +1 -1
- package/dist/ingress.d.ts +10 -0
- package/dist/ingress.d.ts.map +1 -1
- package/dist/normalize.d.ts +1 -0
- package/dist/normalize.d.ts.map +1 -1
- package/dist/render.d.ts +57 -4
- package/dist/render.d.ts.map +1 -1
- package/dist/socket-mode.d.ts +5 -2
- package/dist/socket-mode.d.ts.map +1 -1
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -1,17 +1,30 @@
|
|
|
1
1
|
// src/events.ts
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
createDoctorSummaryPresentation,
|
|
4
|
+
createSourceThreadStatusPresentation,
|
|
5
|
+
parseProjectTargetRef,
|
|
6
|
+
parseThreadActionCommand,
|
|
7
|
+
renderOpenTagPresentationPlainText
|
|
8
|
+
} from "@opentag/core";
|
|
3
9
|
|
|
4
10
|
// src/normalize.ts
|
|
5
11
|
import { commandFromRawText } from "@opentag/core";
|
|
12
|
+
var LEADING_MENTION_RUN = /^(?:<@[^>]+>\s*)+/;
|
|
13
|
+
var MENTION_TOKEN = /<@([^>]+)>/g;
|
|
6
14
|
function stripSlackAppMention(text2, botUserId) {
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
15
|
+
const run = text2.match(LEADING_MENTION_RUN);
|
|
16
|
+
if (!run) return null;
|
|
17
|
+
const leadingRun = run[0];
|
|
18
|
+
if (botUserId) {
|
|
19
|
+
const mentionedIds = leadingRun.match(MENTION_TOKEN) ?? [];
|
|
20
|
+
const botIsMentioned = mentionedIds.some((token) => {
|
|
21
|
+
const id = token.slice(2, -1).split("|")[0] ?? "";
|
|
22
|
+
return id.toLowerCase() === botUserId.toLowerCase();
|
|
23
|
+
});
|
|
24
|
+
if (!botIsMentioned) return null;
|
|
13
25
|
}
|
|
14
|
-
|
|
26
|
+
const stripped = text2.slice(leadingRun.length).trim();
|
|
27
|
+
return stripped.length > 0 ? stripped : null;
|
|
15
28
|
}
|
|
16
29
|
function encodeSlackThreadKey(input) {
|
|
17
30
|
return `${input.teamId}|${input.channelId}|${input.threadTs}`;
|
|
@@ -23,18 +36,27 @@ function parseSlackThreadKey(threadKey) {
|
|
|
23
36
|
}
|
|
24
37
|
return { teamId, channelId, threadTs };
|
|
25
38
|
}
|
|
26
|
-
|
|
39
|
+
var UNKNOWN_WRITE_VERB_PATTERN = /\b(add|append|apply|change|commit|create|delete|edit|fix|modify|open\s+a?\s*pr|pull\s+request|remove|update|write)\b/i;
|
|
40
|
+
var REPO_WRITE_TARGET_PATTERN = /\b(repo|repository|code|file|files|branch|commit|diff|patch|readme|pr|pull\s+request|package\.json|pnpm|npm|test|build)\b|(?:^|\s)[./\w-]+\.(?:cjs|css|gitignore|go|html|js|json|jsx|lock|md|mjs|py|rb|rs|sh|toml|ts|tsx|txt|yaml|yml)\b|(?:^|[\s`'"(])(?:[./\w-]+\/)?(?:Dockerfile|Makefile|Procfile|Rakefile|Gemfile|Brewfile|Justfile|Taskfile|\.dockerignore|\.env(?:\.[\w-]+)?|\.gitignore|\.npmrc)(?=$|[\s`'",.):])/i;
|
|
41
|
+
function commandLooksRepoWriteCapable(command) {
|
|
42
|
+
return UNKNOWN_WRITE_VERB_PATTERN.test(command.rawText) && REPO_WRITE_TARGET_PATTERN.test(command.rawText);
|
|
43
|
+
}
|
|
44
|
+
function permissionsForCommand(command) {
|
|
27
45
|
const permissions = [
|
|
28
46
|
{
|
|
29
47
|
scope: "chat:postMessage",
|
|
30
48
|
reason: "reply in the originating Slack thread"
|
|
31
49
|
},
|
|
50
|
+
{
|
|
51
|
+
scope: "reactions:write",
|
|
52
|
+
reason: "mark the originating Slack message as received without posting a thread reply"
|
|
53
|
+
},
|
|
32
54
|
{
|
|
33
55
|
scope: "runner:local",
|
|
34
56
|
reason: "execute the run on a paired local daemon"
|
|
35
57
|
}
|
|
36
58
|
];
|
|
37
|
-
if (intent === "fix" || intent === "run") {
|
|
59
|
+
if (command.intent === "fix" || command.intent === "run" || command.intent === "unknown" && commandLooksRepoWriteCapable(command)) {
|
|
38
60
|
permissions.push(
|
|
39
61
|
{
|
|
40
62
|
scope: "repo:read",
|
|
@@ -129,7 +151,7 @@ function normalizeSlackAppMention(input) {
|
|
|
129
151
|
},
|
|
130
152
|
...contextPointersForCommand(command)
|
|
131
153
|
],
|
|
132
|
-
permissions:
|
|
154
|
+
permissions: permissionsForCommand(command),
|
|
133
155
|
callback: {
|
|
134
156
|
provider: "slack",
|
|
135
157
|
uri: input.callbackUri ?? "https://slack.com/api/chat.postMessage",
|
|
@@ -143,8 +165,11 @@ function normalizeSlackAppMention(input) {
|
|
|
143
165
|
teamId: input.teamId,
|
|
144
166
|
channelId: input.channelId,
|
|
145
167
|
messageTs: input.ts,
|
|
168
|
+
sourceDeliveryId: input.eventId,
|
|
169
|
+
slackEventId: input.eventId,
|
|
146
170
|
...input.appId ? { slackAppId: input.appId } : {},
|
|
147
171
|
...input.botUserId ? { slackBotUserId: input.botUserId } : {},
|
|
172
|
+
...typeof input.signatureVerified === "boolean" ? { webhookSignatureVerified: input.signatureVerified, signatureState: input.signatureVerified ? "verified" : "unverified" } : {},
|
|
148
173
|
...commandMetadata(command),
|
|
149
174
|
repoProvider: input.binding.repoProvider ?? "github",
|
|
150
175
|
owner: input.binding.owner,
|
|
@@ -153,6 +178,414 @@ function normalizeSlackAppMention(input) {
|
|
|
153
178
|
};
|
|
154
179
|
}
|
|
155
180
|
|
|
181
|
+
// src/render.ts
|
|
182
|
+
import {
|
|
183
|
+
createFinalSummaryPresentation
|
|
184
|
+
} from "@opentag/core";
|
|
185
|
+
var MAX_SLACK_SUGGESTED_ACTION_CANDIDATES = 20;
|
|
186
|
+
function buildSlackSuggestedActionButtonValue(input) {
|
|
187
|
+
return JSON.stringify(input);
|
|
188
|
+
}
|
|
189
|
+
function parseSlackSuggestedActionButtonValue(value) {
|
|
190
|
+
try {
|
|
191
|
+
const parsed = JSON.parse(value);
|
|
192
|
+
if (parsed.version !== 1 || typeof parsed.command !== "string" || parsed.command.trim().length === 0 || typeof parsed.proposalId !== "string" || parsed.proposalId.length === 0 || typeof parsed.intentId !== "string" || parsed.intentId.length === 0) {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
return {
|
|
196
|
+
version: 1,
|
|
197
|
+
command: parsed.command.trim(),
|
|
198
|
+
proposalId: parsed.proposalId,
|
|
199
|
+
intentId: parsed.intentId
|
|
200
|
+
};
|
|
201
|
+
} catch {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
function escapeSlackText(text2) {
|
|
206
|
+
return text2.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
207
|
+
}
|
|
208
|
+
function markdownToSlackMrkdwn(text2) {
|
|
209
|
+
const links = [];
|
|
210
|
+
const withoutLinks = text2.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match, label, url) => {
|
|
211
|
+
const token = `\0SLACK_LINK_${links.length}\0`;
|
|
212
|
+
links.push(`<${url}|${escapeSlackText(label)}>`);
|
|
213
|
+
return token;
|
|
214
|
+
});
|
|
215
|
+
const converted = escapeSlackText(withoutLinks).replace(/\*\*(.+?)\*\*/g, "*$1*").replace(/__(.+?)__/g, "*$1*");
|
|
216
|
+
return links.reduce((output, link, index) => output.replace(`\0SLACK_LINK_${index}\0`, link), converted);
|
|
217
|
+
}
|
|
218
|
+
function markdownToSlackActionDetail(text2) {
|
|
219
|
+
return markdownToSlackMrkdwn(text2).replace(/->/g, "->");
|
|
220
|
+
}
|
|
221
|
+
function renderSlackAcknowledgement(runId) {
|
|
222
|
+
void runId;
|
|
223
|
+
return "Working on it.";
|
|
224
|
+
}
|
|
225
|
+
function slackSourceReceiptReactionName(state) {
|
|
226
|
+
if (state === "received") return "eyes";
|
|
227
|
+
if (state === "running") return "hourglass_flowing_sand";
|
|
228
|
+
return "eyes";
|
|
229
|
+
}
|
|
230
|
+
function createSlackReactionPayload(input) {
|
|
231
|
+
return {
|
|
232
|
+
channel: input.channelId,
|
|
233
|
+
timestamp: input.messageTs,
|
|
234
|
+
name: input.name
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
function truncateSlackText(text2, maxLength) {
|
|
238
|
+
const normalized = text2.replace(/\s+/g, " ").trim();
|
|
239
|
+
if (normalized.length <= maxLength) return normalized;
|
|
240
|
+
return `${normalized.slice(0, Math.max(0, maxLength - 1)).trimEnd()}\u2026`;
|
|
241
|
+
}
|
|
242
|
+
function firstMarkdownSection(text2, heading) {
|
|
243
|
+
const pattern = new RegExp(`\\*\\*${heading}:\\*\\*\\s*([\\s\\S]*?)(?=\\n\\s*\\n\\*\\*[^*]+:\\*\\*|\\n\\s*\\n[A-Z][^\\n]{0,60}:|$)`, "i");
|
|
244
|
+
const match = text2.match(pattern);
|
|
245
|
+
return match?.[1]?.trim();
|
|
246
|
+
}
|
|
247
|
+
function compactSlackSummary(summary) {
|
|
248
|
+
const whatChanged = firstMarkdownSection(summary, "What changed");
|
|
249
|
+
const firstParagraph = summary.split(/\n\s*\n/).map((part) => part.trim()).find(Boolean);
|
|
250
|
+
const selected = whatChanged ?? firstParagraph ?? summary;
|
|
251
|
+
return truncateSlackText(selected.replace(/^\*\*[^*]+:\*\*\s*/i, ""), 360);
|
|
252
|
+
}
|
|
253
|
+
function compactNextAction(nextAction) {
|
|
254
|
+
return truncateSlackText(nextAction, 180);
|
|
255
|
+
}
|
|
256
|
+
function resultForFinalSummaryPresentation(result) {
|
|
257
|
+
if (!result.suggestedChanges) return result;
|
|
258
|
+
const visibleSuggestedChanges = result.suggestedChanges.filter((snapshot) => snapshot.intents.length > 0);
|
|
259
|
+
if (visibleSuggestedChanges.length === result.suggestedChanges.length) return result;
|
|
260
|
+
const { suggestedChanges: _suggestedChanges, ...withoutSuggestedChanges } = result;
|
|
261
|
+
return visibleSuggestedChanges.length > 0 ? { ...withoutSuggestedChanges, suggestedChanges: visibleSuggestedChanges } : withoutSuggestedChanges;
|
|
262
|
+
}
|
|
263
|
+
function slackSection(text2) {
|
|
264
|
+
return {
|
|
265
|
+
type: "section",
|
|
266
|
+
text: {
|
|
267
|
+
type: "mrkdwn",
|
|
268
|
+
text: text2
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
function slackContext(text2) {
|
|
273
|
+
return {
|
|
274
|
+
type: "context",
|
|
275
|
+
elements: [{ type: "mrkdwn", text: text2 }]
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
function slackCheckStatusLabel(status) {
|
|
279
|
+
if (status === "ok") return "OK";
|
|
280
|
+
if (status === "warn") return "WARN";
|
|
281
|
+
if (status === "fail") return "FAIL";
|
|
282
|
+
return "UNKNOWN";
|
|
283
|
+
}
|
|
284
|
+
function slackStatusActiveRun(presentation) {
|
|
285
|
+
if (!presentation.activeRun) return "none";
|
|
286
|
+
return `${presentation.activeRun.id} (${presentation.activeRun.status})${presentation.activeRun.updatedAt ? `, updated ${presentation.activeRun.updatedAt}` : ""}`;
|
|
287
|
+
}
|
|
288
|
+
function slackStatusQueuedFollowUps(presentation) {
|
|
289
|
+
const total = presentation.queuedFollowUpsTotal ?? presentation.queuedFollowUps.length;
|
|
290
|
+
if (total === 0) return "none";
|
|
291
|
+
const visible = presentation.queuedFollowUps.map((followUp) => {
|
|
292
|
+
const status = followUp.status ? ` (${followUp.status})` : "";
|
|
293
|
+
const command = followUp.command ? `: ${markdownToSlackMrkdwn(truncateSlackText(followUp.command, 120))}` : "";
|
|
294
|
+
return `${followUp.id}${status}${command}`;
|
|
295
|
+
});
|
|
296
|
+
const remaining = Math.max(total - visible.length, 0);
|
|
297
|
+
return `${total}${visible.length ? ` (${visible.join(", ")}${remaining > 0 ? `, +${remaining} more` : ""})` : ""}`;
|
|
298
|
+
}
|
|
299
|
+
function renderPresentationActionLines(action) {
|
|
300
|
+
const lines = [`${action.index}. *${markdownToSlackMrkdwn(action.title)}*`];
|
|
301
|
+
lines.push(`Target: ${markdownToSlackMrkdwn(action.targetLabel)}`);
|
|
302
|
+
if (action.setupReason) {
|
|
303
|
+
lines.push(`Status: ${markdownToSlackMrkdwn(action.setupReason)}`);
|
|
304
|
+
}
|
|
305
|
+
if (action.details?.length) {
|
|
306
|
+
lines.push(...action.details.map(markdownToSlackActionDetail));
|
|
307
|
+
}
|
|
308
|
+
return lines;
|
|
309
|
+
}
|
|
310
|
+
var ACTION_RECEIPT_GROUP_ORDER = [
|
|
311
|
+
"ready_to_apply",
|
|
312
|
+
"needs_setup",
|
|
313
|
+
"needs_approval",
|
|
314
|
+
"unsupported"
|
|
315
|
+
];
|
|
316
|
+
function actionReceiptGroupTitle(state) {
|
|
317
|
+
if (state === "ready_to_apply") return "Ready to apply";
|
|
318
|
+
if (state === "needs_setup") return "Needs setup";
|
|
319
|
+
if (state === "unsupported") return "Needs attention";
|
|
320
|
+
return "Needs approval";
|
|
321
|
+
}
|
|
322
|
+
function actionReceiptGroups(actions) {
|
|
323
|
+
return ACTION_RECEIPT_GROUP_ORDER.flatMap((state) => {
|
|
324
|
+
const groupedActions = actions.filter((action) => action.state === state);
|
|
325
|
+
return groupedActions.length > 0 ? [
|
|
326
|
+
{
|
|
327
|
+
state,
|
|
328
|
+
title: actionReceiptGroupTitle(state),
|
|
329
|
+
actions: groupedActions
|
|
330
|
+
}
|
|
331
|
+
] : [];
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
function renderActionReceiptMarkdownLines(input) {
|
|
335
|
+
if (input.actions.length === 0) return [];
|
|
336
|
+
const visibleActions = input.actions.slice(0, MAX_SLACK_SUGGESTED_ACTION_CANDIDATES);
|
|
337
|
+
const groups = actionReceiptGroups(visibleActions);
|
|
338
|
+
const showGroupHeadings = groups.length > 1;
|
|
339
|
+
const lines = [`*${input.title}*`, "", "Choose an action in this thread. Details stay in the OpenTag audit log."];
|
|
340
|
+
for (const group of groups) {
|
|
341
|
+
if (showGroupHeadings) {
|
|
342
|
+
lines.push("", `*${group.title}*`);
|
|
343
|
+
}
|
|
344
|
+
for (const action of group.actions) {
|
|
345
|
+
lines.push("", ...renderPresentationActionLines(action));
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
const remainingCount = input.actions.length - visibleActions.length;
|
|
349
|
+
if (remainingCount > 0) {
|
|
350
|
+
lines.push("", `Showing first ${visibleActions.length} of ${input.actions.length} actions. Reply with an action number for the rest.`);
|
|
351
|
+
}
|
|
352
|
+
lines.push("", "Use the buttons below, or reply with the matching command.");
|
|
353
|
+
return lines;
|
|
354
|
+
}
|
|
355
|
+
function renderSuggestedActionsMarkdown(presentation) {
|
|
356
|
+
const actions = presentation.actions ?? [];
|
|
357
|
+
if (actions.length === 0 || !presentation.actionReceiptTitle) return [];
|
|
358
|
+
return renderActionReceiptMarkdownLines({ title: presentation.actionReceiptTitle, actions });
|
|
359
|
+
}
|
|
360
|
+
function createSuggestedActionButton(action, decision) {
|
|
361
|
+
const index = action.index;
|
|
362
|
+
const labels = {
|
|
363
|
+
apply: `Apply ${index}`,
|
|
364
|
+
approve: "Approve only",
|
|
365
|
+
continue: "Continue",
|
|
366
|
+
reject: "Reject"
|
|
367
|
+
};
|
|
368
|
+
return {
|
|
369
|
+
type: "button",
|
|
370
|
+
text: { type: "plain_text", text: labels[decision], emoji: true },
|
|
371
|
+
action_id: `opentag:${decision}:${index}`,
|
|
372
|
+
value: buildSlackSuggestedActionButtonValue({
|
|
373
|
+
version: 1,
|
|
374
|
+
command: `${decision} ${index}`,
|
|
375
|
+
proposalId: action.proposalId,
|
|
376
|
+
intentId: action.intentId
|
|
377
|
+
}),
|
|
378
|
+
...decision === "apply" ? { style: "primary" } : {},
|
|
379
|
+
...decision === "reject" ? { style: "danger" } : {}
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
function actionHasInteractiveIdentity(action) {
|
|
383
|
+
return Boolean(action.proposalId && action.intentId);
|
|
384
|
+
}
|
|
385
|
+
function createSuggestedActionButtons(action) {
|
|
386
|
+
if (!actionHasInteractiveIdentity(action)) return [];
|
|
387
|
+
return action.visibleDecisions.map((decision) => createSuggestedActionButton(action, decision));
|
|
388
|
+
}
|
|
389
|
+
function createSlackActionReceiptBlockSet(input) {
|
|
390
|
+
const blocks = [];
|
|
391
|
+
if (input.includeDivider) blocks.push({ type: "divider" });
|
|
392
|
+
blocks.push(
|
|
393
|
+
slackSection(`*${markdownToSlackMrkdwn(input.title)}*
|
|
394
|
+
|
|
395
|
+
Choose an action in this thread. Details stay in the OpenTag audit log.`)
|
|
396
|
+
);
|
|
397
|
+
const visibleActions = input.actions.slice(0, MAX_SLACK_SUGGESTED_ACTION_CANDIDATES);
|
|
398
|
+
const groups = actionReceiptGroups(visibleActions);
|
|
399
|
+
const showGroupHeadings = groups.length > 1;
|
|
400
|
+
for (const group of groups) {
|
|
401
|
+
if (showGroupHeadings) {
|
|
402
|
+
blocks.push(slackSection(`*${markdownToSlackMrkdwn(group.title)}*`));
|
|
403
|
+
}
|
|
404
|
+
for (const action of group.actions) {
|
|
405
|
+
blocks.push(slackSection(renderPresentationActionLines(action).join("\n")));
|
|
406
|
+
const buttons = createSuggestedActionButtons(action);
|
|
407
|
+
if (buttons.length === 0) continue;
|
|
408
|
+
blocks.push({
|
|
409
|
+
type: "actions",
|
|
410
|
+
block_id: `opentag_actions_${action.index}`,
|
|
411
|
+
elements: buttons
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
const remainingCount = input.actions.length - visibleActions.length;
|
|
416
|
+
if (remainingCount > 0) {
|
|
417
|
+
blocks.push(slackSection(`Showing first ${visibleActions.length} of ${input.actions.length} actions. Reply with an action number for the rest.`));
|
|
418
|
+
}
|
|
419
|
+
if (input.auditRunId) {
|
|
420
|
+
blocks.push(slackContext(markdownToSlackMrkdwn(`Audit: \`opentag status --run ${input.auditRunId}\``)));
|
|
421
|
+
}
|
|
422
|
+
return blocks;
|
|
423
|
+
}
|
|
424
|
+
function renderSlackActionReceiptPresentation(presentation) {
|
|
425
|
+
const lines = renderActionReceiptMarkdownLines({ title: presentation.title, actions: presentation.actions });
|
|
426
|
+
if (presentation.auditRunId) {
|
|
427
|
+
lines.push("", markdownToSlackMrkdwn(`Audit: \`opentag status --run ${presentation.auditRunId}\``));
|
|
428
|
+
}
|
|
429
|
+
return lines.join("\n");
|
|
430
|
+
}
|
|
431
|
+
function createSlackActionReceiptBlocks(presentation) {
|
|
432
|
+
return createSlackActionReceiptBlockSet({
|
|
433
|
+
title: presentation.title,
|
|
434
|
+
actions: presentation.actions,
|
|
435
|
+
...presentation.auditRunId ? { auditRunId: presentation.auditRunId } : {}
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
function renderSlackFinalResult(result, options = {}) {
|
|
439
|
+
return renderSlackFinalSummaryPresentation(
|
|
440
|
+
createFinalSummaryPresentation({
|
|
441
|
+
result: resultForFinalSummaryPresentation(result),
|
|
442
|
+
...options.receiptContext ? { receiptContext: options.receiptContext } : {},
|
|
443
|
+
...options.auditRunId ? { auditRunId: options.auditRunId } : {}
|
|
444
|
+
})
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
function renderSlackFinalSummaryPresentation(presentation) {
|
|
448
|
+
const lines = [`*Finished: ${presentation.outcome}.*`, markdownToSlackMrkdwn(compactSlackSummary(presentation.summary))];
|
|
449
|
+
if (presentation.verification?.length) {
|
|
450
|
+
lines.push(
|
|
451
|
+
`Verified: ${presentation.verification.slice(0, 3).map((check) => `\`${markdownToSlackMrkdwn(check.command)}\` ${markdownToSlackMrkdwn(check.outcome)}`).join(", ")}`
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
const suggestedActions = renderSuggestedActionsMarkdown(presentation);
|
|
455
|
+
if (presentation.nextActions?.length && suggestedActions.length === 0) {
|
|
456
|
+
lines.push(`Next: ${markdownToSlackMrkdwn(compactNextAction(presentation.nextActions[0] ?? ""))}`);
|
|
457
|
+
}
|
|
458
|
+
if (suggestedActions.length > 0) {
|
|
459
|
+
lines.push("", ...suggestedActions);
|
|
460
|
+
}
|
|
461
|
+
if (presentation.auditRunId) {
|
|
462
|
+
lines.push("", markdownToSlackMrkdwn(`Audit: \`opentag status --run ${presentation.auditRunId}\``));
|
|
463
|
+
}
|
|
464
|
+
return lines.join("\n");
|
|
465
|
+
}
|
|
466
|
+
function createSlackFinalResultBlocks(result, options = {}) {
|
|
467
|
+
return createSlackFinalSummaryBlocks(
|
|
468
|
+
createFinalSummaryPresentation({
|
|
469
|
+
result: resultForFinalSummaryPresentation(result),
|
|
470
|
+
...options.receiptContext ? { receiptContext: options.receiptContext } : {},
|
|
471
|
+
...options.auditRunId ? { auditRunId: options.auditRunId } : {}
|
|
472
|
+
})
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
function createSlackFinalSummaryBlocks(presentation) {
|
|
476
|
+
const blocks = [
|
|
477
|
+
{
|
|
478
|
+
type: "section",
|
|
479
|
+
text: {
|
|
480
|
+
type: "mrkdwn",
|
|
481
|
+
text: `*Finished: ${presentation.outcome}.*
|
|
482
|
+
${markdownToSlackMrkdwn(compactSlackSummary(presentation.summary))}`
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
];
|
|
486
|
+
if (presentation.verification?.length) {
|
|
487
|
+
blocks.push({
|
|
488
|
+
type: "section",
|
|
489
|
+
text: {
|
|
490
|
+
type: "mrkdwn",
|
|
491
|
+
text: `Verified: ${markdownToSlackMrkdwn(
|
|
492
|
+
presentation.verification.slice(0, 3).map((check) => `\`${check.command}\` ${check.outcome}`).join(", ")
|
|
493
|
+
)}`
|
|
494
|
+
}
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
const actions = presentation.actions ?? [];
|
|
498
|
+
if (presentation.nextActions?.length && actions.length === 0) {
|
|
499
|
+
blocks.push({
|
|
500
|
+
type: "section",
|
|
501
|
+
text: {
|
|
502
|
+
type: "mrkdwn",
|
|
503
|
+
text: `Next: ${markdownToSlackMrkdwn(compactNextAction(presentation.nextActions[0] ?? ""))}`
|
|
504
|
+
}
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
if (actions.length > 0 && presentation.actionReceiptTitle) {
|
|
508
|
+
blocks.push(...createSlackActionReceiptBlockSet({ title: presentation.actionReceiptTitle, actions, includeDivider: true }));
|
|
509
|
+
}
|
|
510
|
+
if (presentation.auditRunId) {
|
|
511
|
+
blocks.push({
|
|
512
|
+
type: "context",
|
|
513
|
+
elements: [
|
|
514
|
+
{
|
|
515
|
+
type: "mrkdwn",
|
|
516
|
+
text: markdownToSlackMrkdwn(`Audit: \`opentag status --run ${presentation.auditRunId}\``)
|
|
517
|
+
}
|
|
518
|
+
]
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
return blocks;
|
|
522
|
+
}
|
|
523
|
+
function createSlackDoctorSummaryBlocks(presentation) {
|
|
524
|
+
const blocks = [slackSection(`*${markdownToSlackMrkdwn(presentation.title)}*`)];
|
|
525
|
+
if (presentation.checks.length === 0) {
|
|
526
|
+
blocks.push(slackContext("No readiness checks were reported."));
|
|
527
|
+
return blocks;
|
|
528
|
+
}
|
|
529
|
+
for (const check of presentation.checks.slice(0, 10)) {
|
|
530
|
+
blocks.push(
|
|
531
|
+
slackSection(
|
|
532
|
+
`*${slackCheckStatusLabel(check.status)} ${markdownToSlackMrkdwn(check.name)}*${check.message ? `
|
|
533
|
+
${markdownToSlackMrkdwn(check.message)}` : ""}`
|
|
534
|
+
)
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
if (presentation.checks.length > 10) {
|
|
538
|
+
blocks.push(slackContext(`Showing first 10 of ${presentation.checks.length} readiness checks. Use \`opentag doctor\` locally for full detail.`));
|
|
539
|
+
}
|
|
540
|
+
return blocks;
|
|
541
|
+
}
|
|
542
|
+
function createSlackSourceThreadStatusBlocks(presentation) {
|
|
543
|
+
const blocks = [
|
|
544
|
+
slackSection(`*${markdownToSlackMrkdwn(presentation.title)}*`),
|
|
545
|
+
slackContext(
|
|
546
|
+
[
|
|
547
|
+
presentation.sourceContainer ? `Source: \`${presentation.sourceContainer}\`` : void 0,
|
|
548
|
+
`Project Target: \`${presentation.projectTarget ?? "not bound"}\``
|
|
549
|
+
].filter((line) => Boolean(line)).join(" | ")
|
|
550
|
+
),
|
|
551
|
+
slackSection(
|
|
552
|
+
[
|
|
553
|
+
`*Active run:* ${markdownToSlackMrkdwn(slackStatusActiveRun(presentation))}`,
|
|
554
|
+
presentation.currentCommand ? `*Command:* ${markdownToSlackMrkdwn(presentation.currentCommand)}` : void 0,
|
|
555
|
+
`*Queued follow-ups:* ${markdownToSlackMrkdwn(slackStatusQueuedFollowUps(presentation))}`,
|
|
556
|
+
`*Next action:* ${markdownToSlackMrkdwn(presentation.nextAction)}`
|
|
557
|
+
].filter((line) => Boolean(line)).join("\n")
|
|
558
|
+
)
|
|
559
|
+
];
|
|
560
|
+
if (presentation.stopHint || presentation.detailHint) {
|
|
561
|
+
blocks.push(
|
|
562
|
+
slackContext(
|
|
563
|
+
[
|
|
564
|
+
presentation.stopHint ? `Stop/timeout: ${presentation.stopHint}` : void 0,
|
|
565
|
+
presentation.detailHint ? `Details: ${presentation.detailHint}` : void 0
|
|
566
|
+
].filter((line) => Boolean(line)).map(markdownToSlackMrkdwn).join(" | ")
|
|
567
|
+
)
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
return blocks;
|
|
571
|
+
}
|
|
572
|
+
function createSlackPostMessagePayload(input) {
|
|
573
|
+
return {
|
|
574
|
+
channel: input.channelId,
|
|
575
|
+
text: markdownToSlackMrkdwn(input.text),
|
|
576
|
+
thread_ts: input.threadTs,
|
|
577
|
+
...input.blocks?.length ? { blocks: input.blocks } : {}
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
function createSlackUpdateMessagePayload(input) {
|
|
581
|
+
return {
|
|
582
|
+
channel: input.channelId,
|
|
583
|
+
text: markdownToSlackMrkdwn(input.text),
|
|
584
|
+
ts: input.messageTs,
|
|
585
|
+
...input.blocks?.length ? { blocks: input.blocks } : {}
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
|
|
156
589
|
// src/events.ts
|
|
157
590
|
function json(body, status = 200) {
|
|
158
591
|
return { kind: "json", status, body };
|
|
@@ -160,9 +593,203 @@ function json(body, status = 200) {
|
|
|
160
593
|
function text(body, status = 200) {
|
|
161
594
|
return { kind: "text", status, body };
|
|
162
595
|
}
|
|
596
|
+
var HELP_TEXT = [
|
|
597
|
+
"OpenTag commands:",
|
|
598
|
+
"- @mention the app with `/bind <owner>/<repo>` or `/bind <provider>:<owner>/<repo>` to connect this Slack channel to a Project Target.",
|
|
599
|
+
"- @mention the app with `/status` to see the Project Target, active run, queued follow-ups, and next safe action.",
|
|
600
|
+
"- @mention the app with `/doctor` to see a redacted readiness summary for this Slack channel.",
|
|
601
|
+
"- @mention the app with `/stop [run_id]` to request cancellation for the active channel run or a specific run; OpenTag will not treat stop as successful completion.",
|
|
602
|
+
"- @mention the app with `/unbind confirm` to disconnect this Slack channel from its Project Target; this does not delete local checkout config.",
|
|
603
|
+
"- Reply in a source thread with `apply 1`, `approve 1`, `reject 1`, or `continue 1` when OpenTag posts source-thread actions.",
|
|
604
|
+
"Project Targets never use absolute local paths. Keep local checkout paths in runner config and allowlists."
|
|
605
|
+
].join("\n");
|
|
606
|
+
var BIND_USAGE = "Usage: @mention the app with `/bind <owner>/<repo>` \u2014 e.g. `/bind amplifthq/opentag` or `/bind github:amplifthq/opentag`. Project Targets never use absolute local paths.";
|
|
607
|
+
var UNBIND_USAGE = "Usage: @mention the app with `/unbind confirm` to disconnect this Slack channel from its Project Target. This does not remove local checkout config, repository bindings, or allowlists.";
|
|
608
|
+
var UNBOUND_HINT = "This Slack channel is not connected to a Project Target. @mention the app with `/bind <owner>/<repo>` before starting runs, or update local OpenTag channel bindings.";
|
|
609
|
+
var STOP_UNAVAILABLE_TEXT = [
|
|
610
|
+
"Run cancellation from this Slack ingress is not configured.",
|
|
611
|
+
"OpenTag will not treat a stop request as a successful completion. Use `opentag status --run <run_id>` for audit detail, or `opentag service stop` if you need to stop the local background service."
|
|
612
|
+
].join("\n");
|
|
613
|
+
var BINDING_AUTH_DENIED_TEXT = "Only an authorized Slack binding manager can change this channel's Project Target. Ask an admin to run the command or update local OpenTag channel bindings.";
|
|
614
|
+
function normalizeSelfServiceReply(reply) {
|
|
615
|
+
return typeof reply === "string" ? { text: reply } : reply;
|
|
616
|
+
}
|
|
617
|
+
function parseSelfServiceCommand(command) {
|
|
618
|
+
const trimmed = command?.trim();
|
|
619
|
+
if (!trimmed) return null;
|
|
620
|
+
if (/^\/help(\s|$)/.test(trimmed)) return "help";
|
|
621
|
+
if (/^\/status(\s|$)/.test(trimmed)) return "status";
|
|
622
|
+
if (/^\/doctor(\s|$)/.test(trimmed)) return "doctor";
|
|
623
|
+
return null;
|
|
624
|
+
}
|
|
625
|
+
function parseStopCommand(command) {
|
|
626
|
+
const match = command?.trim().match(/^\/stop(?:\s+(\S+))?\s*$/);
|
|
627
|
+
if (!match) return null;
|
|
628
|
+
return match[1] ? { runId: match[1] } : {};
|
|
629
|
+
}
|
|
630
|
+
function parseBindCommand(command) {
|
|
631
|
+
const trimmed = command?.trim();
|
|
632
|
+
if (!trimmed || !/^\/bind(\s|$)/.test(trimmed)) return null;
|
|
633
|
+
const match = trimmed.match(/^\/bind\s+(\S+)\s*$/);
|
|
634
|
+
if (!match) return { ok: false };
|
|
635
|
+
try {
|
|
636
|
+
const ref = parseProjectTargetRef(match[1]);
|
|
637
|
+
return { ok: true, repoProvider: ref.provider, owner: ref.owner, repo: ref.repo };
|
|
638
|
+
} catch {
|
|
639
|
+
return { ok: false };
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
function parseUnbindCommand(command) {
|
|
643
|
+
const trimmed = command?.trim();
|
|
644
|
+
if (!trimmed || !/^\/unbind(\s|$)/.test(trimmed)) return null;
|
|
645
|
+
return /^\/unbind\s+confirm\s*$/.test(trimmed) ? { ok: true } : { ok: false };
|
|
646
|
+
}
|
|
647
|
+
async function canManageSlackBinding(input, context) {
|
|
648
|
+
if (input.canManageBinding) return input.canManageBinding(context);
|
|
649
|
+
return false;
|
|
650
|
+
}
|
|
651
|
+
function formatStopResultText(result) {
|
|
652
|
+
if (result.outcome === "cancelled") {
|
|
653
|
+
return [
|
|
654
|
+
`Cancellation requested for run ${result.runId}.`,
|
|
655
|
+
"- OpenTag will not treat this stop request as a successful completion.",
|
|
656
|
+
"- The local executor may need a moment to observe the cancellation; further nonessential completion writes are suppressed."
|
|
657
|
+
].join("\n");
|
|
658
|
+
}
|
|
659
|
+
if (result.outcome === "already_terminal") {
|
|
660
|
+
return `Run ${result.runId} is already finished. OpenTag will not change its final result.`;
|
|
661
|
+
}
|
|
662
|
+
return result.runId ? `Run ${result.runId} was not found or is no longer cancelable.` : "No active run was found for this Slack channel and Project Target.";
|
|
663
|
+
}
|
|
664
|
+
function formatProjectTarget(binding) {
|
|
665
|
+
return `${binding.repoProvider ?? "github"}:${binding.owner}/${binding.repo}`;
|
|
666
|
+
}
|
|
667
|
+
function statusPresentation(input) {
|
|
668
|
+
if (!input.binding) {
|
|
669
|
+
return createSourceThreadStatusPresentation({
|
|
670
|
+
title: "OpenTag status:",
|
|
671
|
+
sourceContainer: `slack:${input.teamId}/${input.channelId}`,
|
|
672
|
+
bindingState: "unbound",
|
|
673
|
+
nextAction: "bind this Slack channel to a Project Target before starting runs.",
|
|
674
|
+
detailHint: "active run and queued follow-up status are unavailable until this channel is bound."
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
return createSourceThreadStatusPresentation({
|
|
678
|
+
title: "OpenTag status:",
|
|
679
|
+
sourceContainer: `slack:${input.teamId}/${input.channelId}`,
|
|
680
|
+
projectTarget: formatProjectTarget(input.binding),
|
|
681
|
+
bindingState: "bound",
|
|
682
|
+
nextAction: "mention the app with a task, send a follow-up in the source thread, or use `opentag status --run <run_id>` locally.",
|
|
683
|
+
stopHint: "cancellation is explicit and is not reported as successful completion; timeout policy is recorded in audit/status.",
|
|
684
|
+
detailHint: "at most one run is active per Project Target + source thread; same-thread requests queue behind it."
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
function statusReply(input) {
|
|
688
|
+
const presentation = statusPresentation(input);
|
|
689
|
+
return {
|
|
690
|
+
text: renderOpenTagPresentationPlainText(presentation),
|
|
691
|
+
blocks: createSlackSourceThreadStatusBlocks(presentation)
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
function doctorPresentation(input) {
|
|
695
|
+
return createDoctorSummaryPresentation({
|
|
696
|
+
title: "OpenTag doctor (redacted):",
|
|
697
|
+
checks: [
|
|
698
|
+
{ status: "ok", name: "Source container", message: `slack:${input.teamId}/${input.channelId}` },
|
|
699
|
+
{
|
|
700
|
+
status: input.binding ? "ok" : "warn",
|
|
701
|
+
name: "Project Target",
|
|
702
|
+
message: input.binding ? formatProjectTarget(input.binding) : "not bound"
|
|
703
|
+
},
|
|
704
|
+
{ status: "ok", name: "Secrets", message: "redacted. Use env/file/keychain SecretRef config and never paste tokens into Slack." },
|
|
705
|
+
{
|
|
706
|
+
status: "warn",
|
|
707
|
+
name: "Runtime readiness",
|
|
708
|
+
message: "check `opentag service status` locally; launchd running is not the same as connector ready."
|
|
709
|
+
},
|
|
710
|
+
{ status: "ok", name: "Source-thread output", message: "concise final replies by default; detailed process stays in audit/status." }
|
|
711
|
+
]
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
function doctorReply(input) {
|
|
715
|
+
const presentation = doctorPresentation(input);
|
|
716
|
+
return {
|
|
717
|
+
text: renderOpenTagPresentationPlainText(presentation),
|
|
718
|
+
blocks: createSlackDoctorSummaryBlocks(presentation)
|
|
719
|
+
};
|
|
720
|
+
}
|
|
163
721
|
function createSlackEventProcessor(input) {
|
|
722
|
+
async function processBlockActions(payload, slackApp) {
|
|
723
|
+
const action = payload.actions?.find((candidate) => {
|
|
724
|
+
if (candidate.action_id?.startsWith("opentag:")) return true;
|
|
725
|
+
return typeof candidate.value === "string" && parseSlackSuggestedActionButtonValue(candidate.value) !== null;
|
|
726
|
+
});
|
|
727
|
+
if (!action) {
|
|
728
|
+
return json({ ok: true });
|
|
729
|
+
}
|
|
730
|
+
const parsedValue = typeof action.value === "string" ? parseSlackSuggestedActionButtonValue(action.value) : null;
|
|
731
|
+
const rawText = parsedValue?.command ?? (typeof action.value === "string" && parseThreadActionCommand(action.value) ? action.value.trim() : void 0);
|
|
732
|
+
if (!rawText || !parseThreadActionCommand(rawText)) {
|
|
733
|
+
return json({ error: "invalid_interactive_action" }, 400);
|
|
734
|
+
}
|
|
735
|
+
if (!input.submitThreadAction) {
|
|
736
|
+
return json({ ok: true });
|
|
737
|
+
}
|
|
738
|
+
const teamId = payload.team?.id;
|
|
739
|
+
const userId = payload.user?.id;
|
|
740
|
+
const channelId = payload.channel?.id ?? payload.container?.channel_id;
|
|
741
|
+
const messageTs = payload.message?.ts ?? payload.container?.message_ts;
|
|
742
|
+
const threadTs = payload.message?.thread_ts ?? payload.container?.thread_ts ?? messageTs;
|
|
743
|
+
if (!teamId || !userId || !channelId || !messageTs || !threadTs) {
|
|
744
|
+
return json({ error: "invalid_interactive_payload" }, 400);
|
|
745
|
+
}
|
|
746
|
+
const binding = await input.resolveChannelBinding({
|
|
747
|
+
teamId,
|
|
748
|
+
channelId
|
|
749
|
+
});
|
|
750
|
+
if (!binding) {
|
|
751
|
+
return json({ ok: true, ignored: "unbound_channel" });
|
|
752
|
+
}
|
|
753
|
+
await input.submitThreadAction({
|
|
754
|
+
id: `approval_slack_block_${payload.trigger_id ?? `${action.action_id ?? "action"}_${action.action_ts ?? messageTs}`}`,
|
|
755
|
+
rawText,
|
|
756
|
+
actor: {
|
|
757
|
+
provider: "slack",
|
|
758
|
+
providerUserId: userId,
|
|
759
|
+
handle: payload.user?.username ?? payload.user?.name ?? userId,
|
|
760
|
+
organizationId: teamId
|
|
761
|
+
},
|
|
762
|
+
callback: {
|
|
763
|
+
provider: "slack",
|
|
764
|
+
uri: slackApp.callbackUri ?? "https://slack.com/api/chat.postMessage",
|
|
765
|
+
threadKey: encodeSlackThreadKey({
|
|
766
|
+
teamId,
|
|
767
|
+
channelId,
|
|
768
|
+
threadTs
|
|
769
|
+
})
|
|
770
|
+
},
|
|
771
|
+
metadata: {
|
|
772
|
+
source: "slack_button",
|
|
773
|
+
teamId,
|
|
774
|
+
channelId,
|
|
775
|
+
messageTs,
|
|
776
|
+
...payload.api_app_id ? { slackAppId: payload.api_app_id } : {},
|
|
777
|
+
...action.action_id ? { actionId: action.action_id } : {},
|
|
778
|
+
...action.block_id ? { blockId: action.block_id } : {},
|
|
779
|
+
...action.action_ts ? { actionTs: action.action_ts } : {},
|
|
780
|
+
...parsedValue ? { proposalId: parsedValue.proposalId, intentId: parsedValue.intentId } : {},
|
|
781
|
+
repoProvider: binding.repoProvider ?? "github",
|
|
782
|
+
owner: binding.owner,
|
|
783
|
+
repo: binding.repo
|
|
784
|
+
}
|
|
785
|
+
});
|
|
786
|
+
return json({ ok: true });
|
|
787
|
+
}
|
|
164
788
|
return {
|
|
165
|
-
async process(payload, slackApp) {
|
|
789
|
+
async process(payload, slackApp, verification = {}) {
|
|
790
|
+
if (payload.type === "block_actions") {
|
|
791
|
+
return processBlockActions(payload, slackApp);
|
|
792
|
+
}
|
|
166
793
|
if (payload.type === "url_verification") {
|
|
167
794
|
return text(payload.challenge ?? "");
|
|
168
795
|
}
|
|
@@ -176,6 +803,177 @@ function createSlackEventProcessor(input) {
|
|
|
176
803
|
return json({ error: "invalid_event_payload" }, 400);
|
|
177
804
|
}
|
|
178
805
|
const rawThreadActionText = payload.event.type === "app_mention" ? stripSlackAppMention(payload.event.text, payload.authorizations?.[0]?.user_id) : payload.event.text.trim();
|
|
806
|
+
const bindRequest = payload.event.type === "app_mention" ? parseBindCommand(rawThreadActionText) : null;
|
|
807
|
+
if (bindRequest) {
|
|
808
|
+
const threadTs = payload.event.thread_ts ?? payload.event.ts;
|
|
809
|
+
if (!input.reply) {
|
|
810
|
+
return json({ ok: true, ignored: "self_service_reply_unavailable", command: "bind" });
|
|
811
|
+
}
|
|
812
|
+
if (!input.bindChannel) {
|
|
813
|
+
await input.reply({
|
|
814
|
+
channelId: payload.event.channel,
|
|
815
|
+
threadTs,
|
|
816
|
+
text: "Slack channel binding from source threads is not configured. Re-run `opentag setup` or update local OpenTag channel bindings."
|
|
817
|
+
});
|
|
818
|
+
return json({ ok: true, selfService: "bind", unavailable: true });
|
|
819
|
+
}
|
|
820
|
+
if (!bindRequest.ok) {
|
|
821
|
+
await input.reply({
|
|
822
|
+
channelId: payload.event.channel,
|
|
823
|
+
threadTs,
|
|
824
|
+
text: BIND_USAGE
|
|
825
|
+
});
|
|
826
|
+
return json({ ok: true, selfService: "bind", usage: true });
|
|
827
|
+
}
|
|
828
|
+
const authorized = await canManageSlackBinding(input, {
|
|
829
|
+
action: "bind",
|
|
830
|
+
teamId: payload.team_id,
|
|
831
|
+
channelId: payload.event.channel,
|
|
832
|
+
threadTs,
|
|
833
|
+
userId: payload.event.user,
|
|
834
|
+
eventId: payload.event_id,
|
|
835
|
+
...payload.api_app_id ? { appId: payload.api_app_id } : {}
|
|
836
|
+
});
|
|
837
|
+
if (!authorized) {
|
|
838
|
+
await input.reply({
|
|
839
|
+
channelId: payload.event.channel,
|
|
840
|
+
threadTs,
|
|
841
|
+
text: BINDING_AUTH_DENIED_TEXT
|
|
842
|
+
});
|
|
843
|
+
return json({ ok: true, selfService: "bind", unauthorized: true });
|
|
844
|
+
}
|
|
845
|
+
await input.bindChannel({
|
|
846
|
+
teamId: payload.team_id,
|
|
847
|
+
channelId: payload.event.channel,
|
|
848
|
+
repoProvider: bindRequest.repoProvider,
|
|
849
|
+
owner: bindRequest.owner,
|
|
850
|
+
repo: bindRequest.repo
|
|
851
|
+
});
|
|
852
|
+
await input.reply({
|
|
853
|
+
channelId: payload.event.channel,
|
|
854
|
+
threadTs,
|
|
855
|
+
text: `Connected this Slack channel to Project Target ${bindRequest.repoProvider}:${bindRequest.owner}/${bindRequest.repo}. @mention the app with a task to start a run.`
|
|
856
|
+
});
|
|
857
|
+
return json({ ok: true, selfService: "bind" });
|
|
858
|
+
}
|
|
859
|
+
const unbindRequest = payload.event.type === "app_mention" ? parseUnbindCommand(rawThreadActionText) : null;
|
|
860
|
+
if (unbindRequest) {
|
|
861
|
+
const threadTs = payload.event.thread_ts ?? payload.event.ts;
|
|
862
|
+
if (!input.reply) {
|
|
863
|
+
return json({ ok: true, ignored: "self_service_reply_unavailable", command: "unbind" });
|
|
864
|
+
}
|
|
865
|
+
if (!input.unbindChannel) {
|
|
866
|
+
await input.reply({
|
|
867
|
+
channelId: payload.event.channel,
|
|
868
|
+
threadTs,
|
|
869
|
+
text: "Slack channel unbinding is not enabled in this build. Re-run `opentag setup` or update local OpenTag channel bindings."
|
|
870
|
+
});
|
|
871
|
+
return json({ ok: true, selfService: "unbind", unavailable: true });
|
|
872
|
+
}
|
|
873
|
+
if (!unbindRequest.ok) {
|
|
874
|
+
await input.reply({
|
|
875
|
+
channelId: payload.event.channel,
|
|
876
|
+
threadTs,
|
|
877
|
+
text: UNBIND_USAGE
|
|
878
|
+
});
|
|
879
|
+
return json({ ok: true, selfService: "unbind", usage: true });
|
|
880
|
+
}
|
|
881
|
+
const authorized = await canManageSlackBinding(input, {
|
|
882
|
+
action: "unbind",
|
|
883
|
+
teamId: payload.team_id,
|
|
884
|
+
channelId: payload.event.channel,
|
|
885
|
+
threadTs,
|
|
886
|
+
userId: payload.event.user,
|
|
887
|
+
eventId: payload.event_id,
|
|
888
|
+
...payload.api_app_id ? { appId: payload.api_app_id } : {}
|
|
889
|
+
});
|
|
890
|
+
if (!authorized) {
|
|
891
|
+
await input.reply({
|
|
892
|
+
channelId: payload.event.channel,
|
|
893
|
+
threadTs,
|
|
894
|
+
text: BINDING_AUTH_DENIED_TEXT
|
|
895
|
+
});
|
|
896
|
+
return json({ ok: true, selfService: "unbind", unauthorized: true });
|
|
897
|
+
}
|
|
898
|
+
const binding2 = await input.resolveChannelBinding({
|
|
899
|
+
teamId: payload.team_id,
|
|
900
|
+
channelId: payload.event.channel
|
|
901
|
+
});
|
|
902
|
+
if (!binding2) {
|
|
903
|
+
await input.reply({
|
|
904
|
+
channelId: payload.event.channel,
|
|
905
|
+
threadTs,
|
|
906
|
+
text: UNBOUND_HINT
|
|
907
|
+
});
|
|
908
|
+
return json({ ok: true, selfService: "unbind", ignored: "unbound_channel" });
|
|
909
|
+
}
|
|
910
|
+
await input.unbindChannel({
|
|
911
|
+
teamId: payload.team_id,
|
|
912
|
+
channelId: payload.event.channel
|
|
913
|
+
});
|
|
914
|
+
await input.reply({
|
|
915
|
+
channelId: payload.event.channel,
|
|
916
|
+
threadTs,
|
|
917
|
+
text: `Disconnected this Slack channel from Project Target ${formatProjectTarget(binding2)}. Re-run \`opentag setup\` or update local OpenTag channel bindings to connect a new target.`
|
|
918
|
+
});
|
|
919
|
+
return json({ ok: true, selfService: "unbind" });
|
|
920
|
+
}
|
|
921
|
+
const stopRequest = payload.event.type === "app_mention" ? parseStopCommand(rawThreadActionText) : null;
|
|
922
|
+
if (stopRequest) {
|
|
923
|
+
const threadTs = payload.event.thread_ts ?? payload.event.ts;
|
|
924
|
+
if (!input.stopRun) {
|
|
925
|
+
if (input.reply) {
|
|
926
|
+
await input.reply({
|
|
927
|
+
channelId: payload.event.channel,
|
|
928
|
+
threadTs,
|
|
929
|
+
text: STOP_UNAVAILABLE_TEXT
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
return json({ ok: true, selfService: "stop", unavailable: true });
|
|
933
|
+
}
|
|
934
|
+
const result = await input.stopRun({
|
|
935
|
+
teamId: payload.team_id,
|
|
936
|
+
channelId: payload.event.channel,
|
|
937
|
+
...stopRequest.runId ? { runId: stopRequest.runId } : {},
|
|
938
|
+
requestedBy: `slack:${payload.event.user}`
|
|
939
|
+
});
|
|
940
|
+
if (input.reply) {
|
|
941
|
+
await input.reply({
|
|
942
|
+
channelId: payload.event.channel,
|
|
943
|
+
threadTs,
|
|
944
|
+
text: formatStopResultText(result)
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
return json({ ok: true, selfService: "stop", outcome: result.outcome, ...result.runId ? { runId: result.runId } : {} });
|
|
948
|
+
}
|
|
949
|
+
const selfServiceCommand = payload.event.type === "app_mention" ? parseSelfServiceCommand(rawThreadActionText) : null;
|
|
950
|
+
if (selfServiceCommand) {
|
|
951
|
+
const binding2 = selfServiceCommand === "help" ? null : await input.resolveChannelBinding({
|
|
952
|
+
teamId: payload.team_id,
|
|
953
|
+
channelId: payload.event.channel
|
|
954
|
+
});
|
|
955
|
+
const threadTs = payload.event.thread_ts ?? payload.event.ts;
|
|
956
|
+
if (!input.reply) {
|
|
957
|
+
return json({ ok: true, ignored: "self_service_reply_unavailable", command: selfServiceCommand });
|
|
958
|
+
}
|
|
959
|
+
const context = {
|
|
960
|
+
teamId: payload.team_id,
|
|
961
|
+
channelId: payload.event.channel,
|
|
962
|
+
threadTs,
|
|
963
|
+
userId: payload.event.user,
|
|
964
|
+
binding: binding2
|
|
965
|
+
};
|
|
966
|
+
const reply = selfServiceCommand === "help" ? { text: HELP_TEXT } : normalizeSelfServiceReply(
|
|
967
|
+
selfServiceCommand === "status" ? input.status ? await input.status(context) : statusReply(context) : input.doctor ? await input.doctor(context) : doctorReply(context)
|
|
968
|
+
);
|
|
969
|
+
await input.reply({
|
|
970
|
+
channelId: payload.event.channel,
|
|
971
|
+
threadTs,
|
|
972
|
+
text: reply.text,
|
|
973
|
+
...reply.blocks?.length ? { blocks: reply.blocks } : {}
|
|
974
|
+
});
|
|
975
|
+
return json({ ok: true, selfService: selfServiceCommand });
|
|
976
|
+
}
|
|
179
977
|
if (payload.event.type === "message" && (!rawThreadActionText || !parseThreadActionCommand(rawThreadActionText))) {
|
|
180
978
|
return json({ ok: true });
|
|
181
979
|
}
|
|
@@ -209,8 +1007,11 @@ function createSlackEventProcessor(input) {
|
|
|
209
1007
|
teamId: payload.team_id,
|
|
210
1008
|
channelId: payload.event.channel,
|
|
211
1009
|
messageTs: payload.event.ts,
|
|
1010
|
+
sourceDeliveryId: payload.event_id,
|
|
1011
|
+
slackEventId: payload.event_id,
|
|
212
1012
|
...payload.api_app_id ? { slackAppId: payload.api_app_id } : {},
|
|
213
1013
|
...payload.authorizations?.[0]?.user_id ? { slackBotUserId: payload.authorizations[0].user_id } : {},
|
|
1014
|
+
...typeof verification.signatureVerified === "boolean" ? { webhookSignatureVerified: verification.signatureVerified, signatureState: verification.signatureVerified ? "verified" : "unverified" } : {},
|
|
214
1015
|
repoProvider: binding.repoProvider ?? "github",
|
|
215
1016
|
owner: binding.owner,
|
|
216
1017
|
repo: binding.repo
|
|
@@ -234,7 +1035,8 @@ function createSlackEventProcessor(input) {
|
|
|
234
1035
|
...payload.api_app_id ? { appId: payload.api_app_id } : {},
|
|
235
1036
|
...payload.event.thread_ts ? { threadTs: payload.event.thread_ts } : {},
|
|
236
1037
|
...payload.authorizations?.[0]?.user_id ? { botUserId: payload.authorizations[0].user_id } : {},
|
|
237
|
-
...slackApp.callbackUri ? { callbackUri: slackApp.callbackUri } : {}
|
|
1038
|
+
...slackApp.callbackUri ? { callbackUri: slackApp.callbackUri } : {},
|
|
1039
|
+
...typeof verification.signatureVerified === "boolean" ? { signatureVerified: verification.signatureVerified } : {}
|
|
238
1040
|
});
|
|
239
1041
|
if (!event) {
|
|
240
1042
|
return json({ ok: true, ignored: "empty_command" });
|
|
@@ -248,17 +1050,128 @@ function createSlackEventProcessor(input) {
|
|
|
248
1050
|
// src/ingress.ts
|
|
249
1051
|
import { createHmac, timingSafeEqual } from "crypto";
|
|
250
1052
|
import { serve } from "@hono/node-server";
|
|
1053
|
+
import { createOpenTagClient as createOpenTagClient2 } from "@opentag/client";
|
|
1054
|
+
import { DEFAULT_MAX_REQUEST_BODY_BYTES, RequestBodyTooLargeError, readRequestTextWithLimit } from "@opentag/core";
|
|
251
1055
|
import { Hono } from "hono";
|
|
252
1056
|
|
|
253
1057
|
// src/dispatcher-events.ts
|
|
254
1058
|
import { randomUUID } from "crypto";
|
|
255
1059
|
import { createOpenTagClient } from "@opentag/client";
|
|
1060
|
+
import { createDoctorSummaryPresentation as createDoctorSummaryPresentation2, createSourceThreadStatusPresentation as createSourceThreadStatusPresentation2, renderOpenTagPresentationPlainText as renderOpenTagPresentationPlainText2 } from "@opentag/core";
|
|
1061
|
+
function formatProjectTarget2(input) {
|
|
1062
|
+
return `${input.repoProvider ?? "github"}:${input.owner}/${input.repo}`;
|
|
1063
|
+
}
|
|
1064
|
+
function queuedFollowUpsSummary(status) {
|
|
1065
|
+
if (status.queuedFollowUps.length === 0) return "none.";
|
|
1066
|
+
const visible = status.queuedFollowUps.slice(0, 3).map((followUp) => followUp.id);
|
|
1067
|
+
const suffix = status.queuedFollowUps.length > visible.length ? `, +${status.queuedFollowUps.length - visible.length} more` : "";
|
|
1068
|
+
return `${status.queuedFollowUps.length} (${visible.join(", ")}${suffix}).`;
|
|
1069
|
+
}
|
|
1070
|
+
function formatDurationMs(ms) {
|
|
1071
|
+
if (ms % 6e4 === 0) return `${ms / 6e4} minute(s)`;
|
|
1072
|
+
if (ms % 1e3 === 0) return `${ms / 1e3} second(s)`;
|
|
1073
|
+
return `${ms}ms`;
|
|
1074
|
+
}
|
|
1075
|
+
function runTimeoutPolicy(input) {
|
|
1076
|
+
const hardTimeoutMs = input.status?.runTimeoutPolicy?.hardTimeoutMs ?? input.runTimeoutMs;
|
|
1077
|
+
return hardTimeoutMs ? `hard timeout after ${formatDurationMs(hardTimeoutMs)}` : "disabled";
|
|
1078
|
+
}
|
|
1079
|
+
function slackRuntimeStatusReply(status, input = {}) {
|
|
1080
|
+
const presentation = createSourceThreadStatusPresentation2({
|
|
1081
|
+
title: "OpenTag status:",
|
|
1082
|
+
sourceContainer: `${status.binding.provider}:${status.binding.accountId}/${status.binding.conversationId}`,
|
|
1083
|
+
projectTarget: formatProjectTarget2(status.binding),
|
|
1084
|
+
bindingState: "bound",
|
|
1085
|
+
...status.activeRun ? {
|
|
1086
|
+
activeRun: {
|
|
1087
|
+
id: status.activeRun.id,
|
|
1088
|
+
status: status.activeRun.status,
|
|
1089
|
+
updatedAt: status.activeRun.updatedAt
|
|
1090
|
+
}
|
|
1091
|
+
} : {},
|
|
1092
|
+
...status.activeEvent?.command.rawText ? { currentCommand: status.activeEvent.command.rawText } : {},
|
|
1093
|
+
queuedFollowUps: status.queuedFollowUps.slice(0, 3).map((followUp) => ({
|
|
1094
|
+
id: followUp.id,
|
|
1095
|
+
status: followUp.status,
|
|
1096
|
+
command: followUp.event.command.rawText
|
|
1097
|
+
})),
|
|
1098
|
+
queuedFollowUpsTotal: status.queuedFollowUps.length,
|
|
1099
|
+
nextAction: status.activeRun ? "wait for the final reply, send a follow-up to queue more context, or use `opentag status --run <run_id>` locally." : "@mention the app with a task to start a run.",
|
|
1100
|
+
stopHint: `cancellation is explicit and is not reported as successful completion; timeout policy: ${runTimeoutPolicy({ ...input, status })}.`,
|
|
1101
|
+
detailHint: "use `opentag status --run <run_id>` locally for audit events and executor detail."
|
|
1102
|
+
});
|
|
1103
|
+
return {
|
|
1104
|
+
text: renderOpenTagPresentationPlainText2(presentation),
|
|
1105
|
+
blocks: createSlackSourceThreadStatusBlocks(presentation)
|
|
1106
|
+
};
|
|
1107
|
+
}
|
|
1108
|
+
function slackRuntimeDoctorReply(input) {
|
|
1109
|
+
const presentation = createDoctorSummaryPresentation2({
|
|
1110
|
+
title: "OpenTag doctor (redacted):",
|
|
1111
|
+
checks: [
|
|
1112
|
+
{ status: "ok", name: "Source container", message: `slack:${input.teamId}/${input.channelId}` },
|
|
1113
|
+
{ status: "ok", name: "Project Target", message: formatProjectTarget2(input.status.binding) },
|
|
1114
|
+
{ status: "ok", name: "Dispatcher", message: "reachable for this source container." },
|
|
1115
|
+
{
|
|
1116
|
+
status: "ok",
|
|
1117
|
+
name: "Active run",
|
|
1118
|
+
message: input.status.activeRun ? `${input.status.activeRun.id} (${input.status.activeRun.status}), updated ${input.status.activeRun.updatedAt}.` : "none."
|
|
1119
|
+
},
|
|
1120
|
+
{ status: "ok", name: "Queued follow-ups", message: queuedFollowUpsSummary(input.status) },
|
|
1121
|
+
{ status: "ok", name: "Timeout policy", message: runTimeoutPolicy({ ...input, status: input.status }) },
|
|
1122
|
+
{
|
|
1123
|
+
status: "ok",
|
|
1124
|
+
name: "Runtime readiness",
|
|
1125
|
+
message: "source-container status is reachable; run `opentag service status` locally to confirm controller, connector, executor, and heartbeat health."
|
|
1126
|
+
},
|
|
1127
|
+
{ status: "ok", name: "Secrets", message: "redacted. Use env/file/keychain SecretRef config and never paste tokens into Slack." }
|
|
1128
|
+
]
|
|
1129
|
+
});
|
|
1130
|
+
return {
|
|
1131
|
+
text: renderOpenTagPresentationPlainText2(presentation),
|
|
1132
|
+
blocks: createSlackDoctorSummaryBlocks(presentation)
|
|
1133
|
+
};
|
|
1134
|
+
}
|
|
1135
|
+
function statusUnavailable(input) {
|
|
1136
|
+
const message = input.error instanceof Error ? input.error.message : String(input.error);
|
|
1137
|
+
return [
|
|
1138
|
+
"OpenTag status:",
|
|
1139
|
+
input.binding ? `Project Target: ${formatProjectTarget2(input.binding)}` : "Project Target: not bound.",
|
|
1140
|
+
"Runtime status: unavailable from dispatcher.",
|
|
1141
|
+
`Reason: ${message}`,
|
|
1142
|
+
"Next action: check `opentag service status` and `opentag status` locally."
|
|
1143
|
+
].join("\n");
|
|
1144
|
+
}
|
|
1145
|
+
function doctorUnavailable(input) {
|
|
1146
|
+
const message = input.error instanceof Error ? input.error.message : String(input.error);
|
|
1147
|
+
return [
|
|
1148
|
+
"OpenTag doctor (redacted):",
|
|
1149
|
+
`Source container: slack:${input.teamId}/${input.channelId}`,
|
|
1150
|
+
input.binding ? `Project Target: ${formatProjectTarget2(input.binding)}` : "Project Target: not bound.",
|
|
1151
|
+
"Dispatcher: source-container status unavailable.",
|
|
1152
|
+
`Reason: ${message}`,
|
|
1153
|
+
"Runtime readiness: run `opentag service status` and `opentag status --channel slack:<team>/<channel>` locally.",
|
|
1154
|
+
"Secrets: redacted; do not share local config or app tokens in Slack."
|
|
1155
|
+
].join("\n");
|
|
1156
|
+
}
|
|
1157
|
+
function mapStopError(input) {
|
|
1158
|
+
const message = input.error instanceof Error ? input.error.message : String(input.error);
|
|
1159
|
+
if (message.includes("run_already_terminal")) {
|
|
1160
|
+
return { outcome: "already_terminal", runId: input.runId ?? "active run" };
|
|
1161
|
+
}
|
|
1162
|
+
if (message.includes("run_not_found") || message.includes("active_run_not_found") || message.includes("channel_binding_not_found")) {
|
|
1163
|
+
return input.runId ? { outcome: "not_found", runId: input.runId } : { outcome: "not_found" };
|
|
1164
|
+
}
|
|
1165
|
+
return null;
|
|
1166
|
+
}
|
|
256
1167
|
function createSlackDispatcherEventProcessorInput(config) {
|
|
1168
|
+
const fetchImpl = config.fetchImpl ?? fetch;
|
|
257
1169
|
const dispatcherClient = createOpenTagClient({
|
|
258
1170
|
dispatcherUrl: config.dispatcherUrl,
|
|
259
|
-
...config.dispatcherToken ? { pairingToken: config.dispatcherToken } : {}
|
|
1171
|
+
...config.dispatcherToken ? { pairingToken: config.dispatcherToken } : {},
|
|
1172
|
+
fetchImpl
|
|
260
1173
|
});
|
|
261
|
-
|
|
1174
|
+
const processorInput = {
|
|
262
1175
|
async resolveChannelBinding(input) {
|
|
263
1176
|
try {
|
|
264
1177
|
const { binding } = await dispatcherClient.getChannelBinding({
|
|
@@ -285,11 +1198,112 @@ function createSlackDispatcherEventProcessorInput(config) {
|
|
|
285
1198
|
const created = await dispatcherClient.createRun({ runId, event });
|
|
286
1199
|
return created.outcome === "run_created" ? { runId: created.run.id } : { runId };
|
|
287
1200
|
},
|
|
1201
|
+
async bindChannel(input) {
|
|
1202
|
+
await dispatcherClient.bindChannel({
|
|
1203
|
+
provider: "slack",
|
|
1204
|
+
accountId: input.teamId,
|
|
1205
|
+
conversationId: input.channelId,
|
|
1206
|
+
repoProvider: input.repoProvider,
|
|
1207
|
+
owner: input.owner,
|
|
1208
|
+
repo: input.repo
|
|
1209
|
+
});
|
|
1210
|
+
},
|
|
288
1211
|
async submitThreadAction(action) {
|
|
289
1212
|
await dispatcherClient.submitThreadAction(action);
|
|
290
1213
|
},
|
|
1214
|
+
async unbindChannel(input) {
|
|
1215
|
+
await dispatcherClient.unbindChannel({
|
|
1216
|
+
provider: "slack",
|
|
1217
|
+
accountId: input.teamId,
|
|
1218
|
+
conversationId: input.channelId
|
|
1219
|
+
});
|
|
1220
|
+
},
|
|
1221
|
+
canManageBinding(input) {
|
|
1222
|
+
return Boolean(config.bindingAdminUserIds?.includes(input.userId));
|
|
1223
|
+
},
|
|
1224
|
+
async stopRun(input) {
|
|
1225
|
+
try {
|
|
1226
|
+
const result = input.runId ? await dispatcherClient.cancelRun({
|
|
1227
|
+
runId: input.runId,
|
|
1228
|
+
reason: "Stop requested from Slack.",
|
|
1229
|
+
requestedBy: input.requestedBy
|
|
1230
|
+
}) : await dispatcherClient.cancelActiveChannelRun({
|
|
1231
|
+
provider: "slack",
|
|
1232
|
+
accountId: input.teamId,
|
|
1233
|
+
conversationId: input.channelId,
|
|
1234
|
+
reason: "Stop requested from Slack.",
|
|
1235
|
+
requestedBy: input.requestedBy
|
|
1236
|
+
});
|
|
1237
|
+
return { outcome: "cancelled", runId: result.run.id };
|
|
1238
|
+
} catch (error) {
|
|
1239
|
+
const mapped = mapStopError({ error, ...input.runId ? { runId: input.runId } : {} });
|
|
1240
|
+
if (mapped) return mapped;
|
|
1241
|
+
throw error;
|
|
1242
|
+
}
|
|
1243
|
+
},
|
|
1244
|
+
async status(input) {
|
|
1245
|
+
if (!input.binding) return statusUnavailable({ error: "channel not bound" });
|
|
1246
|
+
try {
|
|
1247
|
+
return slackRuntimeStatusReply(
|
|
1248
|
+
await dispatcherClient.getChannelRuntimeStatus({
|
|
1249
|
+
provider: "slack",
|
|
1250
|
+
accountId: input.teamId,
|
|
1251
|
+
conversationId: input.channelId
|
|
1252
|
+
}),
|
|
1253
|
+
{ ...config.runTimeoutMs ? { runTimeoutMs: config.runTimeoutMs } : {} }
|
|
1254
|
+
);
|
|
1255
|
+
} catch (error) {
|
|
1256
|
+
return statusUnavailable({ binding: input.binding, error });
|
|
1257
|
+
}
|
|
1258
|
+
},
|
|
1259
|
+
async doctor(input) {
|
|
1260
|
+
if (!input.binding) {
|
|
1261
|
+
return doctorUnavailable({ teamId: input.teamId, channelId: input.channelId, error: "channel not bound" });
|
|
1262
|
+
}
|
|
1263
|
+
try {
|
|
1264
|
+
return slackRuntimeDoctorReply({
|
|
1265
|
+
teamId: input.teamId,
|
|
1266
|
+
channelId: input.channelId,
|
|
1267
|
+
status: await dispatcherClient.getChannelRuntimeStatus({
|
|
1268
|
+
provider: "slack",
|
|
1269
|
+
accountId: input.teamId,
|
|
1270
|
+
conversationId: input.channelId
|
|
1271
|
+
}),
|
|
1272
|
+
...config.runTimeoutMs ? { runTimeoutMs: config.runTimeoutMs } : {}
|
|
1273
|
+
});
|
|
1274
|
+
} catch (error) {
|
|
1275
|
+
return doctorUnavailable({ teamId: input.teamId, channelId: input.channelId, binding: input.binding, error });
|
|
1276
|
+
}
|
|
1277
|
+
},
|
|
291
1278
|
now: () => (/* @__PURE__ */ new Date()).toISOString()
|
|
292
1279
|
};
|
|
1280
|
+
if (config.botToken) {
|
|
1281
|
+
processorInput.reply = async (input) => {
|
|
1282
|
+
const response = await fetchImpl(config.callbackUri ?? "https://slack.com/api/chat.postMessage", {
|
|
1283
|
+
method: "POST",
|
|
1284
|
+
headers: {
|
|
1285
|
+
authorization: `Bearer ${config.botToken}`,
|
|
1286
|
+
"content-type": "application/json"
|
|
1287
|
+
},
|
|
1288
|
+
body: JSON.stringify(
|
|
1289
|
+
createSlackPostMessagePayload({
|
|
1290
|
+
channelId: input.channelId,
|
|
1291
|
+
threadTs: input.threadTs,
|
|
1292
|
+
text: input.text,
|
|
1293
|
+
...input.blocks?.length ? { blocks: input.blocks } : {}
|
|
1294
|
+
})
|
|
1295
|
+
)
|
|
1296
|
+
});
|
|
1297
|
+
if (!response.ok) {
|
|
1298
|
+
throw new Error(`deliver Slack self-service reply failed: ${response.status} ${await response.text()}`);
|
|
1299
|
+
}
|
|
1300
|
+
const body = await response.json().catch(() => ({}));
|
|
1301
|
+
if (body.ok === false) {
|
|
1302
|
+
throw new Error(`deliver Slack self-service reply failed: ${body.error ?? "unknown_error"}`);
|
|
1303
|
+
}
|
|
1304
|
+
};
|
|
1305
|
+
}
|
|
1306
|
+
return processorInput;
|
|
293
1307
|
}
|
|
294
1308
|
|
|
295
1309
|
// src/ingress.ts
|
|
@@ -311,11 +1325,101 @@ function verifySlackTimestamp(input) {
|
|
|
311
1325
|
const ageSeconds = Math.abs(Math.floor(input.nowMs / 1e3) - timestampSeconds);
|
|
312
1326
|
return ageSeconds <= toleranceSeconds;
|
|
313
1327
|
}
|
|
1328
|
+
async function recordSlackSignatureFailure(input) {
|
|
1329
|
+
try {
|
|
1330
|
+
await input.recordControlPlaneEvent?.({
|
|
1331
|
+
type: "security.signature_failed",
|
|
1332
|
+
severity: "warn",
|
|
1333
|
+
subject: "slack:POST /slack/events",
|
|
1334
|
+
payload: {
|
|
1335
|
+
provider: "slack",
|
|
1336
|
+
endpoint: "POST /slack/events",
|
|
1337
|
+
reason: input.reason,
|
|
1338
|
+
hasSignature: input.hasSignature,
|
|
1339
|
+
hasTimestamp: input.hasTimestamp,
|
|
1340
|
+
...input.apiAppId ? { apiAppId: input.apiAppId } : {}
|
|
1341
|
+
}
|
|
1342
|
+
});
|
|
1343
|
+
} catch {
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
async function recordSlackRequestBodyRejected(input) {
|
|
1347
|
+
try {
|
|
1348
|
+
await input.recordControlPlaneEvent?.({
|
|
1349
|
+
type: "security.request_body_rejected",
|
|
1350
|
+
severity: "warn",
|
|
1351
|
+
subject: "slack:POST /slack/events",
|
|
1352
|
+
payload: {
|
|
1353
|
+
provider: "slack",
|
|
1354
|
+
endpoint: "POST /slack/events",
|
|
1355
|
+
reason: input.reason,
|
|
1356
|
+
...input.maxBytes !== void 0 ? { maxBytes: input.maxBytes } : {},
|
|
1357
|
+
contentLength: input.contentLength,
|
|
1358
|
+
...input.apiAppId ? { apiAppId: input.apiAppId } : {}
|
|
1359
|
+
}
|
|
1360
|
+
});
|
|
1361
|
+
} catch {
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
function isRecord(value) {
|
|
1365
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
1366
|
+
}
|
|
1367
|
+
function hasOptionalStringProperties(value, keys) {
|
|
1368
|
+
return keys.every((key) => value[key] === void 0 || typeof value[key] === "string");
|
|
1369
|
+
}
|
|
1370
|
+
function isSlackEventEnvelope(value) {
|
|
1371
|
+
if (value.type !== "url_verification" && value.type !== "event_callback") return false;
|
|
1372
|
+
if (!hasOptionalStringProperties(value, ["token", "challenge", "team_id", "api_app_id", "event_id"])) return false;
|
|
1373
|
+
if (value.event_time !== void 0 && typeof value.event_time !== "number") return false;
|
|
1374
|
+
if (value.authorizations !== void 0) {
|
|
1375
|
+
if (!Array.isArray(value.authorizations)) return false;
|
|
1376
|
+
if (!value.authorizations.every(
|
|
1377
|
+
(authorization) => isRecord(authorization) && (authorization.user_id === void 0 || typeof authorization.user_id === "string")
|
|
1378
|
+
)) {
|
|
1379
|
+
return false;
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
if (value.event !== void 0) {
|
|
1383
|
+
if (!isRecord(value.event) || typeof value.event.type !== "string") return false;
|
|
1384
|
+
if (!hasOptionalStringProperties(value.event, ["user", "text", "ts", "thread_ts", "channel", "subtype", "bot_id"])) {
|
|
1385
|
+
return false;
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
return true;
|
|
1389
|
+
}
|
|
1390
|
+
function isSlackInteractivePayload(value) {
|
|
1391
|
+
if (value.type !== "block_actions") return false;
|
|
1392
|
+
if (!hasOptionalStringProperties(value, ["api_app_id", "trigger_id"])) return false;
|
|
1393
|
+
if (value.team !== void 0 && (!isRecord(value.team) || !hasOptionalStringProperties(value.team, ["id", "domain"]))) return false;
|
|
1394
|
+
if (value.user !== void 0 && (!isRecord(value.user) || !hasOptionalStringProperties(value.user, ["id", "username", "name"]))) return false;
|
|
1395
|
+
if (value.channel !== void 0 && (!isRecord(value.channel) || !hasOptionalStringProperties(value.channel, ["id", "name"]))) return false;
|
|
1396
|
+
if (value.message !== void 0 && (!isRecord(value.message) || !hasOptionalStringProperties(value.message, ["ts", "thread_ts"]))) return false;
|
|
1397
|
+
if (value.container !== void 0 && (!isRecord(value.container) || !hasOptionalStringProperties(value.container, ["type", "channel_id", "message_ts", "thread_ts"]))) {
|
|
1398
|
+
return false;
|
|
1399
|
+
}
|
|
1400
|
+
if (value.actions !== void 0) {
|
|
1401
|
+
if (!Array.isArray(value.actions)) return false;
|
|
1402
|
+
return value.actions.every(
|
|
1403
|
+
(action) => isRecord(action) && hasOptionalStringProperties(action, ["type", "action_id", "block_id", "value", "action_ts"])
|
|
1404
|
+
);
|
|
1405
|
+
}
|
|
1406
|
+
return true;
|
|
1407
|
+
}
|
|
1408
|
+
function isSlackIngressPayload(value) {
|
|
1409
|
+
if (!isRecord(value) || typeof value.type !== "string") return false;
|
|
1410
|
+
return isSlackEventEnvelope(value) || isSlackInteractivePayload(value);
|
|
1411
|
+
}
|
|
314
1412
|
function createSlackEventsApp(input) {
|
|
315
1413
|
const app = new Hono();
|
|
316
1414
|
const processor = createSlackEventProcessor(input);
|
|
317
|
-
|
|
1415
|
+
const maxRequestBodyBytes = input.maxRequestBodyBytes ?? DEFAULT_MAX_REQUEST_BODY_BYTES;
|
|
1416
|
+
function parseSlackPayload(rawBody, contentType) {
|
|
318
1417
|
try {
|
|
1418
|
+
if (contentType?.includes("application/x-www-form-urlencoded") || rawBody.startsWith("payload=")) {
|
|
1419
|
+
const interactivePayload = new URLSearchParams(rawBody).get("payload");
|
|
1420
|
+
if (!interactivePayload) return null;
|
|
1421
|
+
return JSON.parse(interactivePayload);
|
|
1422
|
+
}
|
|
319
1423
|
return JSON.parse(rawBody);
|
|
320
1424
|
} catch {
|
|
321
1425
|
return null;
|
|
@@ -340,16 +1444,56 @@ function createSlackEventsApp(input) {
|
|
|
340
1444
|
const timestamp = c.req.header("x-slack-request-timestamp");
|
|
341
1445
|
const signature = c.req.header("x-slack-signature");
|
|
342
1446
|
if (!timestamp || !signature) {
|
|
1447
|
+
await recordSlackSignatureFailure({
|
|
1448
|
+
recordControlPlaneEvent: input.recordControlPlaneEvent,
|
|
1449
|
+
reason: "missing_signature_headers",
|
|
1450
|
+
hasSignature: Boolean(signature),
|
|
1451
|
+
hasTimestamp: Boolean(timestamp)
|
|
1452
|
+
});
|
|
343
1453
|
return c.json({ error: "missing_signature_headers" }, 401);
|
|
344
1454
|
}
|
|
345
1455
|
if (!verifySlackTimestamp({ timestamp, nowMs: input.clock?.() ?? Date.now() })) {
|
|
1456
|
+
await recordSlackSignatureFailure({
|
|
1457
|
+
recordControlPlaneEvent: input.recordControlPlaneEvent,
|
|
1458
|
+
reason: "stale_signature_timestamp",
|
|
1459
|
+
hasSignature: true,
|
|
1460
|
+
hasTimestamp: true
|
|
1461
|
+
});
|
|
346
1462
|
return c.json({ error: "stale_signature_timestamp" }, 401);
|
|
347
1463
|
}
|
|
348
|
-
|
|
349
|
-
|
|
1464
|
+
let rawBody;
|
|
1465
|
+
try {
|
|
1466
|
+
rawBody = await readRequestTextWithLimit(c.req.raw, { maxBytes: maxRequestBodyBytes });
|
|
1467
|
+
} catch (error) {
|
|
1468
|
+
if (error instanceof RequestBodyTooLargeError) {
|
|
1469
|
+
await recordSlackRequestBodyRejected({
|
|
1470
|
+
recordControlPlaneEvent: input.recordControlPlaneEvent,
|
|
1471
|
+
reason: "request_body_too_large",
|
|
1472
|
+
maxBytes: error.maxBytes,
|
|
1473
|
+
contentLength: c.req.raw.headers.get("content-length")
|
|
1474
|
+
});
|
|
1475
|
+
return c.json({ error: "request_body_too_large", maxBytes: error.maxBytes }, 413);
|
|
1476
|
+
}
|
|
1477
|
+
throw error;
|
|
1478
|
+
}
|
|
1479
|
+
const payload = parseSlackPayload(rawBody, c.req.header("content-type"));
|
|
350
1480
|
if (!payload) {
|
|
1481
|
+
await recordSlackRequestBodyRejected({
|
|
1482
|
+
recordControlPlaneEvent: input.recordControlPlaneEvent,
|
|
1483
|
+
reason: "invalid_json_body",
|
|
1484
|
+
contentLength: c.req.raw.headers.get("content-length")
|
|
1485
|
+
});
|
|
351
1486
|
return c.json({ error: "invalid_json" }, 400);
|
|
352
1487
|
}
|
|
1488
|
+
if (!isSlackIngressPayload(payload)) {
|
|
1489
|
+
await recordSlackRequestBodyRejected({
|
|
1490
|
+
recordControlPlaneEvent: input.recordControlPlaneEvent,
|
|
1491
|
+
reason: "invalid_request_body",
|
|
1492
|
+
contentLength: c.req.raw.headers.get("content-length"),
|
|
1493
|
+
...isRecord(payload) && typeof payload.api_app_id === "string" ? { apiAppId: payload.api_app_id } : {}
|
|
1494
|
+
});
|
|
1495
|
+
return c.json({ error: "invalid_request_body" }, 400);
|
|
1496
|
+
}
|
|
353
1497
|
const resolvedSlackApp = resolveSlackApp({
|
|
354
1498
|
rawBody,
|
|
355
1499
|
signature,
|
|
@@ -357,9 +1501,16 @@ function createSlackEventsApp(input) {
|
|
|
357
1501
|
...payload.api_app_id ? { apiAppId: payload.api_app_id } : {}
|
|
358
1502
|
});
|
|
359
1503
|
if ("error" in resolvedSlackApp) {
|
|
1504
|
+
await recordSlackSignatureFailure({
|
|
1505
|
+
recordControlPlaneEvent: input.recordControlPlaneEvent,
|
|
1506
|
+
reason: resolvedSlackApp.error,
|
|
1507
|
+
hasSignature: true,
|
|
1508
|
+
hasTimestamp: true,
|
|
1509
|
+
...payload.api_app_id ? { apiAppId: payload.api_app_id } : {}
|
|
1510
|
+
});
|
|
360
1511
|
return c.json({ error: resolvedSlackApp.error }, 401);
|
|
361
1512
|
}
|
|
362
|
-
const result = await processor.process(payload, resolvedSlackApp.slackApp);
|
|
1513
|
+
const result = await processor.process(payload, resolvedSlackApp.slackApp, { signatureVerified: true });
|
|
363
1514
|
if (result.kind === "text") {
|
|
364
1515
|
return c.text(result.body, result.status);
|
|
365
1516
|
}
|
|
@@ -369,6 +1520,10 @@ function createSlackEventsApp(input) {
|
|
|
369
1520
|
}
|
|
370
1521
|
function startSlackIngress(config) {
|
|
371
1522
|
const port = config.port ?? 3040;
|
|
1523
|
+
const dispatcherClient = createOpenTagClient2({
|
|
1524
|
+
dispatcherUrl: config.dispatcherUrl,
|
|
1525
|
+
...config.dispatcherToken ? { pairingToken: config.dispatcherToken } : {}
|
|
1526
|
+
});
|
|
372
1527
|
const server = serve({
|
|
373
1528
|
fetch: createSlackEventsApp({
|
|
374
1529
|
slackApps: [
|
|
@@ -379,6 +1534,10 @@ function startSlackIngress(config) {
|
|
|
379
1534
|
...config.callbackUri ? { callbackUri: config.callbackUri } : {}
|
|
380
1535
|
}
|
|
381
1536
|
],
|
|
1537
|
+
...config.maxRequestBodyBytes ? { maxRequestBodyBytes: config.maxRequestBodyBytes } : {},
|
|
1538
|
+
async recordControlPlaneEvent(event) {
|
|
1539
|
+
await dispatcherClient.recordControlPlaneEvent(event);
|
|
1540
|
+
},
|
|
382
1541
|
...createSlackDispatcherEventProcessorInput(config)
|
|
383
1542
|
}).fetch,
|
|
384
1543
|
port
|
|
@@ -400,185 +1559,26 @@ function startSlackIngress(config) {
|
|
|
400
1559
|
};
|
|
401
1560
|
}
|
|
402
1561
|
|
|
403
|
-
// src/render.ts
|
|
404
|
-
import { suggestedActionCandidatesFromResult } from "@opentag/core";
|
|
405
|
-
function escapeSlackText(text2) {
|
|
406
|
-
return text2.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
407
|
-
}
|
|
408
|
-
function markdownToSlackMrkdwn(text2) {
|
|
409
|
-
const links = [];
|
|
410
|
-
const withoutLinks = text2.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match, label, url) => {
|
|
411
|
-
const token = `\0SLACK_LINK_${links.length}\0`;
|
|
412
|
-
links.push(`<${url}|${escapeSlackText(label)}>`);
|
|
413
|
-
return token;
|
|
414
|
-
});
|
|
415
|
-
const converted = escapeSlackText(withoutLinks).replace(/\*\*(.+?)\*\*/g, "*$1*").replace(/__(.+?)__/g, "*$1*");
|
|
416
|
-
return links.reduce((output, link, index) => output.replace(`\0SLACK_LINK_${index}\0`, link), converted);
|
|
417
|
-
}
|
|
418
|
-
function renderSlackAcknowledgement(runId) {
|
|
419
|
-
return `I picked this up: \`${runId}\``;
|
|
420
|
-
}
|
|
421
|
-
function nextActionSummary(result) {
|
|
422
|
-
if (!result.nextAction) return void 0;
|
|
423
|
-
if (typeof result.nextAction === "string") return result.nextAction;
|
|
424
|
-
return result.nextAction.summary;
|
|
425
|
-
}
|
|
426
|
-
function stringParam(params, key) {
|
|
427
|
-
const value = params?.[key];
|
|
428
|
-
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
429
|
-
}
|
|
430
|
-
function stringArrayParam(params, key) {
|
|
431
|
-
const value = params?.[key];
|
|
432
|
-
if (!Array.isArray(value)) return [];
|
|
433
|
-
return value.filter((item) => typeof item === "string" && item.length > 0);
|
|
434
|
-
}
|
|
435
|
-
function renderVerificationParams(params) {
|
|
436
|
-
const value = params?.["verification"];
|
|
437
|
-
if (!Array.isArray(value)) return [];
|
|
438
|
-
return value.map((item) => {
|
|
439
|
-
if (!item || typeof item !== "object" || Array.isArray(item)) return void 0;
|
|
440
|
-
const command = item["command"];
|
|
441
|
-
const outcome = item["outcome"];
|
|
442
|
-
return typeof command === "string" && typeof outcome === "string" ? ` - \`${command}\`: ${outcome}` : void 0;
|
|
443
|
-
}).filter((line) => Boolean(line));
|
|
444
|
-
}
|
|
445
|
-
function renderSuggestedActionDetails(params, action) {
|
|
446
|
-
if (action !== "create_pull_request") return [];
|
|
447
|
-
const lines = [];
|
|
448
|
-
const title = stringParam(params, "title");
|
|
449
|
-
const head = stringParam(params, "head") ?? stringParam(params, "branch");
|
|
450
|
-
const base = stringParam(params, "base") ?? stringParam(params, "baseBranch");
|
|
451
|
-
const changedFiles = stringArrayParam(params, "changedFiles");
|
|
452
|
-
const risks = stringArrayParam(params, "risks");
|
|
453
|
-
const verification = renderVerificationParams(params);
|
|
454
|
-
if (title) lines.push(` Title: ${markdownToSlackMrkdwn(title)}`);
|
|
455
|
-
if (head || base) lines.push(` Branch: \`${head ?? "unknown"}\` -> \`${base ?? "main"}\``);
|
|
456
|
-
if (changedFiles.length > 0) lines.push(` Changed files: ${changedFiles.map((file) => `\`${file}\``).join(", ")}`);
|
|
457
|
-
if (risks.length > 0) {
|
|
458
|
-
lines.push(" Risks:");
|
|
459
|
-
for (const risk of risks) {
|
|
460
|
-
lines.push(` - ${markdownToSlackMrkdwn(risk)}`);
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
if (verification.length > 0) {
|
|
464
|
-
lines.push(" Verification:");
|
|
465
|
-
lines.push(...verification);
|
|
466
|
-
}
|
|
467
|
-
return lines;
|
|
468
|
-
}
|
|
469
|
-
function renderSuggestedActionsMarkdown(result) {
|
|
470
|
-
const candidates = suggestedActionCandidatesFromResult(result);
|
|
471
|
-
if (candidates.length === 0) return [];
|
|
472
|
-
const lines = ["*Suggested actions*"];
|
|
473
|
-
for (const candidate of candidates) {
|
|
474
|
-
lines.push(
|
|
475
|
-
"",
|
|
476
|
-
`${candidate.index}. *${markdownToSlackMrkdwn(candidate.intent.summary)}*`,
|
|
477
|
-
` Intent: \`${candidate.intent.action}\` (\`${candidate.intent.domain}\`)`,
|
|
478
|
-
` Proposal: \`${candidate.proposalId}\``,
|
|
479
|
-
` Intent ID: \`${candidate.intent.intentId}\``
|
|
480
|
-
);
|
|
481
|
-
lines.push(...renderSuggestedActionDetails(candidate.intent.params, candidate.intent.action));
|
|
482
|
-
if (candidate.proposalPreconditions?.length) {
|
|
483
|
-
lines.push(" Preconditions:");
|
|
484
|
-
for (const precondition of candidate.proposalPreconditions) {
|
|
485
|
-
lines.push(` - ${markdownToSlackMrkdwn(precondition)}`);
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
}
|
|
489
|
-
lines.push(
|
|
490
|
-
"",
|
|
491
|
-
"Reply with:",
|
|
492
|
-
"- `approve 1` to record approval",
|
|
493
|
-
"- `apply 1` or `apply all` to apply supported actions",
|
|
494
|
-
"- `continue 1` to continue with a follow-up run",
|
|
495
|
-
"- `reject 1` to reject an action"
|
|
496
|
-
);
|
|
497
|
-
return lines;
|
|
498
|
-
}
|
|
499
|
-
function renderSlackFinalResult(result) {
|
|
500
|
-
const lines = [`Finished with *${result.conclusion}*.`, "", markdownToSlackMrkdwn(result.summary)];
|
|
501
|
-
if (result.verification?.length) {
|
|
502
|
-
lines.push("", "*Verification*");
|
|
503
|
-
for (const check of result.verification) {
|
|
504
|
-
lines.push(`- \`${check.command}\`: ${check.outcome}`);
|
|
505
|
-
}
|
|
506
|
-
}
|
|
507
|
-
const nextAction = nextActionSummary(result);
|
|
508
|
-
if (nextAction) {
|
|
509
|
-
lines.push("", `*Next action*: ${markdownToSlackMrkdwn(nextAction)}`);
|
|
510
|
-
}
|
|
511
|
-
const suggestedActions = renderSuggestedActionsMarkdown(result);
|
|
512
|
-
if (suggestedActions.length > 0) {
|
|
513
|
-
lines.push("", ...suggestedActions);
|
|
514
|
-
}
|
|
515
|
-
return lines.join("\n");
|
|
516
|
-
}
|
|
517
|
-
function createSlackFinalResultBlocks(result) {
|
|
518
|
-
const blocks = [
|
|
519
|
-
{
|
|
520
|
-
type: "section",
|
|
521
|
-
text: {
|
|
522
|
-
type: "mrkdwn",
|
|
523
|
-
text: `*Finished with ${result.conclusion}.*
|
|
524
|
-
${markdownToSlackMrkdwn(result.summary)}`
|
|
525
|
-
}
|
|
526
|
-
}
|
|
527
|
-
];
|
|
528
|
-
if (result.verification?.length) {
|
|
529
|
-
blocks.push({ type: "divider" });
|
|
530
|
-
blocks.push({
|
|
531
|
-
type: "section",
|
|
532
|
-
text: {
|
|
533
|
-
type: "mrkdwn",
|
|
534
|
-
text: markdownToSlackMrkdwn(["*Verification*", ...result.verification.map((check) => `- \`${check.command}\`: ${check.outcome}`)].join("\n"))
|
|
535
|
-
}
|
|
536
|
-
});
|
|
537
|
-
}
|
|
538
|
-
const nextAction = nextActionSummary(result);
|
|
539
|
-
if (nextAction) {
|
|
540
|
-
blocks.push({
|
|
541
|
-
type: "section",
|
|
542
|
-
text: {
|
|
543
|
-
type: "mrkdwn",
|
|
544
|
-
text: `*Next action*: ${markdownToSlackMrkdwn(nextAction)}`
|
|
545
|
-
}
|
|
546
|
-
});
|
|
547
|
-
}
|
|
548
|
-
const suggestedActions = renderSuggestedActionsMarkdown(result);
|
|
549
|
-
if (suggestedActions.length > 0) {
|
|
550
|
-
blocks.push({ type: "divider" });
|
|
551
|
-
blocks.push({
|
|
552
|
-
type: "section",
|
|
553
|
-
text: {
|
|
554
|
-
type: "mrkdwn",
|
|
555
|
-
text: suggestedActions.join("\n")
|
|
556
|
-
}
|
|
557
|
-
});
|
|
558
|
-
}
|
|
559
|
-
return blocks;
|
|
560
|
-
}
|
|
561
|
-
function createSlackPostMessagePayload(input) {
|
|
562
|
-
return {
|
|
563
|
-
channel: input.channelId,
|
|
564
|
-
text: markdownToSlackMrkdwn(input.text),
|
|
565
|
-
thread_ts: input.threadTs,
|
|
566
|
-
...input.blocks?.length ? { blocks: input.blocks } : {}
|
|
567
|
-
};
|
|
568
|
-
}
|
|
569
|
-
function createSlackUpdateMessagePayload(input) {
|
|
570
|
-
return {
|
|
571
|
-
channel: input.channelId,
|
|
572
|
-
text: markdownToSlackMrkdwn(input.text),
|
|
573
|
-
ts: input.messageTs,
|
|
574
|
-
...input.blocks?.length ? { blocks: input.blocks } : {}
|
|
575
|
-
};
|
|
576
|
-
}
|
|
577
|
-
|
|
578
1562
|
// src/socket-mode.ts
|
|
579
1563
|
import WebSocket from "ws";
|
|
1564
|
+
import { createOpenTagClient as createOpenTagClient3 } from "@opentag/client";
|
|
580
1565
|
var SLACK_CONNECTIONS_OPEN_URL = "https://slack.com/api/apps.connections.open";
|
|
581
1566
|
var DEFAULT_RECONNECT_DELAY_MS = 1e3;
|
|
1567
|
+
var TERMINAL_SLACK_ERROR_CODES = [
|
|
1568
|
+
"invalid_auth",
|
|
1569
|
+
"not_authed",
|
|
1570
|
+
"account_inactive",
|
|
1571
|
+
"token_revoked",
|
|
1572
|
+
"token_expired",
|
|
1573
|
+
"not_allowed_token_type",
|
|
1574
|
+
"no_permission",
|
|
1575
|
+
"missing_scope",
|
|
1576
|
+
"ekm_access_denied"
|
|
1577
|
+
];
|
|
1578
|
+
function terminalSlackAuthErrorCode(error) {
|
|
1579
|
+
if (!(error instanceof Error)) return null;
|
|
1580
|
+
return TERMINAL_SLACK_ERROR_CODES.find((code) => error.message.includes(code)) ?? null;
|
|
1581
|
+
}
|
|
582
1582
|
function rawDataToString(data) {
|
|
583
1583
|
if (typeof data === "string") return data;
|
|
584
1584
|
if (Buffer.isBuffer(data)) return data.toString("utf8");
|
|
@@ -614,17 +1614,39 @@ async function handleSocketMessage(input) {
|
|
|
614
1614
|
return;
|
|
615
1615
|
}
|
|
616
1616
|
input.socket.send(JSON.stringify({ envelope_id: envelope.envelope_id }));
|
|
617
|
-
if (
|
|
1617
|
+
if (!envelope.payload) {
|
|
618
1618
|
return;
|
|
619
1619
|
}
|
|
620
1620
|
if (input.slackApp.appId && envelope.payload.api_app_id && envelope.payload.api_app_id !== input.slackApp.appId) {
|
|
621
1621
|
return;
|
|
622
1622
|
}
|
|
1623
|
+
if (envelope.type !== "events_api" && envelope.payload.type !== "block_actions") {
|
|
1624
|
+
return;
|
|
1625
|
+
}
|
|
623
1626
|
await input.processor.process(envelope.payload, input.slackApp);
|
|
624
1627
|
}
|
|
625
1628
|
function wait(ms) {
|
|
626
1629
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
627
1630
|
}
|
|
1631
|
+
async function recordSlackSocketTokenMisuse(input) {
|
|
1632
|
+
try {
|
|
1633
|
+
await input.recordControlPlaneEvent?.({
|
|
1634
|
+
type: "security.token_misuse",
|
|
1635
|
+
severity: "warn",
|
|
1636
|
+
subject: "slack:app_token",
|
|
1637
|
+
payload: {
|
|
1638
|
+
provider: "slack",
|
|
1639
|
+
endpoint: "apps.connections.open",
|
|
1640
|
+
reason: input.reason,
|
|
1641
|
+
tokenKind: "app_token",
|
|
1642
|
+
mode: "socket_mode",
|
|
1643
|
+
agentId: input.slackApp.agentId,
|
|
1644
|
+
...input.slackApp.appId ? { appId: input.slackApp.appId } : {}
|
|
1645
|
+
}
|
|
1646
|
+
});
|
|
1647
|
+
} catch {
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
628
1650
|
function startSlackSocketModeApp(input, dependencies = {}) {
|
|
629
1651
|
const fetchImpl = dependencies.fetchImpl ?? fetch;
|
|
630
1652
|
const createWebSocket = dependencies.createWebSocket ?? ((url) => new WebSocket(url));
|
|
@@ -671,8 +1693,26 @@ function startSlackSocketModeApp(input, dependencies = {}) {
|
|
|
671
1693
|
}
|
|
672
1694
|
const startPromise = (async () => {
|
|
673
1695
|
while (!closed) {
|
|
674
|
-
|
|
675
|
-
|
|
1696
|
+
try {
|
|
1697
|
+
const socketUrl = await openSlackSocketUrl({ appToken: input.appToken, fetchImpl });
|
|
1698
|
+
await runOneConnection(socketUrl);
|
|
1699
|
+
} catch (error) {
|
|
1700
|
+
const terminalErrorCode = terminalSlackAuthErrorCode(error);
|
|
1701
|
+
if (terminalErrorCode) {
|
|
1702
|
+
if (!closed) {
|
|
1703
|
+
logError("[slack] terminal Socket Mode auth/config error, aborting:", error);
|
|
1704
|
+
await recordSlackSocketTokenMisuse({
|
|
1705
|
+
recordControlPlaneEvent: input.recordControlPlaneEvent,
|
|
1706
|
+
slackApp: input.slackApp,
|
|
1707
|
+
reason: terminalErrorCode
|
|
1708
|
+
});
|
|
1709
|
+
}
|
|
1710
|
+
throw error;
|
|
1711
|
+
}
|
|
1712
|
+
if (!closed) {
|
|
1713
|
+
logError("[slack] failed to open Socket Mode connection, retrying:", error);
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
676
1716
|
if (!closed) {
|
|
677
1717
|
await wait(reconnectDelayMs);
|
|
678
1718
|
}
|
|
@@ -688,6 +1728,11 @@ function startSlackSocketModeApp(input, dependencies = {}) {
|
|
|
688
1728
|
};
|
|
689
1729
|
}
|
|
690
1730
|
function startSlackSocketModeIngress(config, dependencies = {}) {
|
|
1731
|
+
const dispatcherClient = createOpenTagClient3({
|
|
1732
|
+
dispatcherUrl: config.dispatcherUrl,
|
|
1733
|
+
...config.dispatcherToken ? { pairingToken: config.dispatcherToken } : {},
|
|
1734
|
+
fetchImpl: config.fetchImpl ?? fetch
|
|
1735
|
+
});
|
|
691
1736
|
return startSlackSocketModeApp(
|
|
692
1737
|
{
|
|
693
1738
|
appToken: config.appToken,
|
|
@@ -696,24 +1741,37 @@ function startSlackSocketModeIngress(config, dependencies = {}) {
|
|
|
696
1741
|
...config.appId ? { appId: config.appId } : {},
|
|
697
1742
|
...config.callbackUri ? { callbackUri: config.callbackUri } : {}
|
|
698
1743
|
},
|
|
1744
|
+
async recordControlPlaneEvent(event) {
|
|
1745
|
+
await dispatcherClient.recordControlPlaneEvent(event);
|
|
1746
|
+
},
|
|
699
1747
|
...createSlackDispatcherEventProcessorInput(config)
|
|
700
1748
|
},
|
|
701
1749
|
dependencies
|
|
702
1750
|
);
|
|
703
1751
|
}
|
|
704
1752
|
export {
|
|
1753
|
+
buildSlackSuggestedActionButtonValue,
|
|
705
1754
|
computeSlackSignature,
|
|
1755
|
+
createSlackActionReceiptBlocks,
|
|
1756
|
+
createSlackDoctorSummaryBlocks,
|
|
706
1757
|
createSlackEventProcessor,
|
|
707
1758
|
createSlackEventsApp,
|
|
708
1759
|
createSlackFinalResultBlocks,
|
|
1760
|
+
createSlackFinalSummaryBlocks,
|
|
709
1761
|
createSlackPostMessagePayload,
|
|
1762
|
+
createSlackReactionPayload,
|
|
1763
|
+
createSlackSourceThreadStatusBlocks,
|
|
710
1764
|
createSlackUpdateMessagePayload,
|
|
711
1765
|
encodeSlackThreadKey,
|
|
712
1766
|
markdownToSlackMrkdwn,
|
|
713
1767
|
normalizeSlackAppMention,
|
|
1768
|
+
parseSlackSuggestedActionButtonValue,
|
|
714
1769
|
parseSlackThreadKey,
|
|
715
1770
|
renderSlackAcknowledgement,
|
|
1771
|
+
renderSlackActionReceiptPresentation,
|
|
716
1772
|
renderSlackFinalResult,
|
|
1773
|
+
renderSlackFinalSummaryPresentation,
|
|
1774
|
+
slackSourceReceiptReactionName,
|
|
717
1775
|
startSlackIngress,
|
|
718
1776
|
startSlackSocketModeApp,
|
|
719
1777
|
startSlackSocketModeIngress,
|