@opentag/slack 0.3.0 → 0.3.2
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 +61 -1
- package/dist/events.d.ts.map +1 -1
- package/dist/index.js +986 -161
- 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 +22 -5
- package/dist/render.d.ts.map +1 -1
- package/dist/socket-mode.d.ts +3 -0
- package/dist/socket-mode.d.ts.map +1 -1
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
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";
|
|
@@ -159,8 +165,11 @@ function normalizeSlackAppMention(input) {
|
|
|
159
165
|
teamId: input.teamId,
|
|
160
166
|
channelId: input.channelId,
|
|
161
167
|
messageTs: input.ts,
|
|
168
|
+
sourceDeliveryId: input.eventId,
|
|
169
|
+
slackEventId: input.eventId,
|
|
162
170
|
...input.appId ? { slackAppId: input.appId } : {},
|
|
163
171
|
...input.botUserId ? { slackBotUserId: input.botUserId } : {},
|
|
172
|
+
...typeof input.signatureVerified === "boolean" ? { webhookSignatureVerified: input.signatureVerified, signatureState: input.signatureVerified ? "verified" : "unverified" } : {},
|
|
164
173
|
...commandMetadata(command),
|
|
165
174
|
repoProvider: input.binding.repoProvider ?? "github",
|
|
166
175
|
owner: input.binding.owner,
|
|
@@ -170,7 +179,9 @@ function normalizeSlackAppMention(input) {
|
|
|
170
179
|
}
|
|
171
180
|
|
|
172
181
|
// src/render.ts
|
|
173
|
-
import {
|
|
182
|
+
import {
|
|
183
|
+
createFinalSummaryPresentation
|
|
184
|
+
} from "@opentag/core";
|
|
174
185
|
var MAX_SLACK_SUGGESTED_ACTION_CANDIDATES = 20;
|
|
175
186
|
function buildSlackSuggestedActionButtonValue(input) {
|
|
176
187
|
return JSON.stringify(input);
|
|
@@ -204,12 +215,16 @@ function markdownToSlackMrkdwn(text2) {
|
|
|
204
215
|
const converted = escapeSlackText(withoutLinks).replace(/\*\*(.+?)\*\*/g, "*$1*").replace(/__(.+?)__/g, "*$1*");
|
|
205
216
|
return links.reduce((output, link, index) => output.replace(`\0SLACK_LINK_${index}\0`, link), converted);
|
|
206
217
|
}
|
|
218
|
+
function markdownToSlackActionDetail(text2) {
|
|
219
|
+
return markdownToSlackMrkdwn(text2).replace(/->/g, "->");
|
|
220
|
+
}
|
|
207
221
|
function renderSlackAcknowledgement(runId) {
|
|
208
222
|
void runId;
|
|
209
223
|
return "Working on it.";
|
|
210
224
|
}
|
|
211
225
|
function slackSourceReceiptReactionName(state) {
|
|
212
226
|
if (state === "received") return "eyes";
|
|
227
|
+
if (state === "running") return "hourglass_flowing_sand";
|
|
213
228
|
return "eyes";
|
|
214
229
|
}
|
|
215
230
|
function createSlackReactionPayload(input) {
|
|
@@ -219,54 +234,6 @@ function createSlackReactionPayload(input) {
|
|
|
219
234
|
name: input.name
|
|
220
235
|
};
|
|
221
236
|
}
|
|
222
|
-
function nextActionSummary(result) {
|
|
223
|
-
if (!result.nextAction) return void 0;
|
|
224
|
-
if (typeof result.nextAction === "string") return result.nextAction;
|
|
225
|
-
return result.nextAction.summary;
|
|
226
|
-
}
|
|
227
|
-
function stringParam(params, key) {
|
|
228
|
-
const value = params?.[key];
|
|
229
|
-
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
230
|
-
}
|
|
231
|
-
function stringArrayParam(params, key) {
|
|
232
|
-
const value = params?.[key];
|
|
233
|
-
if (!Array.isArray(value)) return [];
|
|
234
|
-
return value.filter((item) => typeof item === "string" && item.length > 0);
|
|
235
|
-
}
|
|
236
|
-
function renderVerificationParams(params) {
|
|
237
|
-
const value = params?.["verification"];
|
|
238
|
-
if (!Array.isArray(value)) return [];
|
|
239
|
-
return value.map((item) => {
|
|
240
|
-
if (!item || typeof item !== "object" || Array.isArray(item)) return void 0;
|
|
241
|
-
const command = item["command"];
|
|
242
|
-
const outcome = item["outcome"];
|
|
243
|
-
return typeof command === "string" && typeof outcome === "string" ? ` - \`${command}\`: ${outcome}` : void 0;
|
|
244
|
-
}).filter((line) => Boolean(line));
|
|
245
|
-
}
|
|
246
|
-
function renderSuggestedActionDetails(params, action) {
|
|
247
|
-
if (action !== "create_pull_request") return [];
|
|
248
|
-
const lines = [];
|
|
249
|
-
const title = stringParam(params, "title");
|
|
250
|
-
const head = stringParam(params, "head") ?? stringParam(params, "branch");
|
|
251
|
-
const base = stringParam(params, "base") ?? stringParam(params, "baseBranch");
|
|
252
|
-
const changedFiles = stringArrayParam(params, "changedFiles");
|
|
253
|
-
const risks = stringArrayParam(params, "risks");
|
|
254
|
-
const verification = renderVerificationParams(params);
|
|
255
|
-
if (title) lines.push(` Title: ${markdownToSlackMrkdwn(title)}`);
|
|
256
|
-
if (head || base) lines.push(` Branch: \`${head ?? "unknown"}\` -> \`${base ?? "main"}\``);
|
|
257
|
-
if (changedFiles.length > 0) lines.push(` Changed files: ${changedFiles.map((file) => `\`${file}\``).join(", ")}`);
|
|
258
|
-
if (risks.length > 0) {
|
|
259
|
-
lines.push(" Risks:");
|
|
260
|
-
for (const risk of risks) {
|
|
261
|
-
lines.push(` - ${markdownToSlackMrkdwn(risk)}`);
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
if (verification.length > 0) {
|
|
265
|
-
lines.push(" Verification:");
|
|
266
|
-
lines.push(...verification);
|
|
267
|
-
}
|
|
268
|
-
return lines;
|
|
269
|
-
}
|
|
270
237
|
function truncateSlackText(text2, maxLength) {
|
|
271
238
|
const normalized = text2.replace(/\s+/g, " ").trim();
|
|
272
239
|
if (normalized.length <= maxLength) return normalized;
|
|
@@ -286,153 +253,319 @@ function compactSlackSummary(summary) {
|
|
|
286
253
|
function compactNextAction(nextAction) {
|
|
287
254
|
return truncateSlackText(nextAction, 180);
|
|
288
255
|
}
|
|
289
|
-
function
|
|
290
|
-
|
|
291
|
-
const
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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));
|
|
295
307
|
}
|
|
296
308
|
return lines;
|
|
297
309
|
}
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
+
}
|
|
305
347
|
}
|
|
306
|
-
const remainingCount =
|
|
348
|
+
const remainingCount = input.actions.length - visibleActions.length;
|
|
307
349
|
if (remainingCount > 0) {
|
|
308
|
-
lines.push("", `Showing first ${
|
|
350
|
+
lines.push("", `Showing first ${visibleActions.length} of ${input.actions.length} actions. Reply with an action number for the rest.`);
|
|
309
351
|
}
|
|
310
|
-
lines.push("", "Use the buttons below, or reply
|
|
352
|
+
lines.push("", "Use the buttons below, or reply with the matching command.");
|
|
311
353
|
return lines;
|
|
312
354
|
}
|
|
313
|
-
function
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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)}*`));
|
|
349
403
|
}
|
|
350
|
-
|
|
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
|
+
);
|
|
351
446
|
}
|
|
352
|
-
function
|
|
353
|
-
const lines = [`*Finished: ${
|
|
354
|
-
if (
|
|
447
|
+
function renderSlackFinalSummaryPresentation(presentation) {
|
|
448
|
+
const lines = [`*Finished: ${presentation.outcome}.*`, markdownToSlackMrkdwn(compactSlackSummary(presentation.summary))];
|
|
449
|
+
if (presentation.verification?.length) {
|
|
355
450
|
lines.push(
|
|
356
|
-
`Verified: ${
|
|
451
|
+
`Verified: ${presentation.verification.slice(0, 3).map((check) => `\`${markdownToSlackMrkdwn(check.command)}\` ${markdownToSlackMrkdwn(check.outcome)}`).join(", ")}`
|
|
357
452
|
);
|
|
358
453
|
}
|
|
359
|
-
const
|
|
360
|
-
if (
|
|
361
|
-
lines.push(`Next: ${markdownToSlackMrkdwn(compactNextAction(
|
|
454
|
+
const suggestedActions = renderSuggestedActionsMarkdown(presentation);
|
|
455
|
+
if (presentation.nextActions?.length && suggestedActions.length === 0) {
|
|
456
|
+
lines.push(`Next: ${markdownToSlackMrkdwn(compactNextAction(presentation.nextActions[0] ?? ""))}`);
|
|
362
457
|
}
|
|
363
|
-
const suggestedActions = renderSuggestedActionsMarkdown(result);
|
|
364
458
|
if (suggestedActions.length > 0) {
|
|
365
459
|
lines.push("", ...suggestedActions);
|
|
366
460
|
}
|
|
461
|
+
if (presentation.auditRunId) {
|
|
462
|
+
lines.push("", markdownToSlackMrkdwn(`Audit: \`opentag status --run ${presentation.auditRunId}\``));
|
|
463
|
+
}
|
|
367
464
|
return lines.join("\n");
|
|
368
465
|
}
|
|
369
|
-
function createSlackFinalResultBlocks(result) {
|
|
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) {
|
|
370
476
|
const blocks = [
|
|
371
477
|
{
|
|
372
478
|
type: "section",
|
|
373
479
|
text: {
|
|
374
480
|
type: "mrkdwn",
|
|
375
|
-
text: `*Finished: ${
|
|
376
|
-
${markdownToSlackMrkdwn(compactSlackSummary(
|
|
481
|
+
text: `*Finished: ${presentation.outcome}.*
|
|
482
|
+
${markdownToSlackMrkdwn(compactSlackSummary(presentation.summary))}`
|
|
377
483
|
}
|
|
378
484
|
}
|
|
379
485
|
];
|
|
380
|
-
if (
|
|
486
|
+
if (presentation.verification?.length) {
|
|
381
487
|
blocks.push({
|
|
382
488
|
type: "section",
|
|
383
489
|
text: {
|
|
384
490
|
type: "mrkdwn",
|
|
385
491
|
text: `Verified: ${markdownToSlackMrkdwn(
|
|
386
|
-
|
|
492
|
+
presentation.verification.slice(0, 3).map((check) => `\`${check.command}\` ${check.outcome}`).join(", ")
|
|
387
493
|
)}`
|
|
388
494
|
}
|
|
389
495
|
});
|
|
390
496
|
}
|
|
391
|
-
const
|
|
392
|
-
|
|
393
|
-
if (nextAction && suggestedActionCandidates.length === 0) {
|
|
497
|
+
const actions = presentation.actions ?? [];
|
|
498
|
+
if (presentation.nextActions?.length && actions.length === 0) {
|
|
394
499
|
blocks.push({
|
|
395
500
|
type: "section",
|
|
396
501
|
text: {
|
|
397
502
|
type: "mrkdwn",
|
|
398
|
-
text: `Next: ${markdownToSlackMrkdwn(compactNextAction(
|
|
503
|
+
text: `Next: ${markdownToSlackMrkdwn(compactNextAction(presentation.nextActions[0] ?? ""))}`
|
|
399
504
|
}
|
|
400
505
|
});
|
|
401
506
|
}
|
|
402
|
-
if (
|
|
403
|
-
blocks.push({
|
|
507
|
+
if (actions.length > 0 && presentation.actionReceiptTitle) {
|
|
508
|
+
blocks.push(...createSlackActionReceiptBlockSet({ title: presentation.actionReceiptTitle, actions, includeDivider: true }));
|
|
509
|
+
}
|
|
510
|
+
if (presentation.auditRunId) {
|
|
404
511
|
blocks.push({
|
|
405
|
-
type: "
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
text: "*Suggested actions*\nChoose an action in this thread. Details stay in the OpenTag audit log."
|
|
409
|
-
}
|
|
410
|
-
});
|
|
411
|
-
const visibleCandidates = suggestedActionCandidates.slice(0, MAX_SLACK_SUGGESTED_ACTION_CANDIDATES);
|
|
412
|
-
for (const candidate of visibleCandidates) {
|
|
413
|
-
blocks.push({
|
|
414
|
-
type: "section",
|
|
415
|
-
text: {
|
|
416
|
-
type: "mrkdwn",
|
|
417
|
-
text: renderSuggestedActionCandidateLines(candidate).join("\n")
|
|
418
|
-
}
|
|
419
|
-
});
|
|
420
|
-
blocks.push({
|
|
421
|
-
type: "actions",
|
|
422
|
-
block_id: `opentag_actions_${candidate.index}`,
|
|
423
|
-
elements: createSuggestedActionButtons(candidate)
|
|
424
|
-
});
|
|
425
|
-
}
|
|
426
|
-
const remainingCount = suggestedActionCandidates.length - visibleCandidates.length;
|
|
427
|
-
if (remainingCount > 0) {
|
|
428
|
-
blocks.push({
|
|
429
|
-
type: "section",
|
|
430
|
-
text: {
|
|
512
|
+
type: "context",
|
|
513
|
+
elements: [
|
|
514
|
+
{
|
|
431
515
|
type: "mrkdwn",
|
|
432
|
-
text: `
|
|
516
|
+
text: markdownToSlackMrkdwn(`Audit: \`opentag status --run ${presentation.auditRunId}\``)
|
|
433
517
|
}
|
|
434
|
-
|
|
435
|
-
}
|
|
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
|
+
);
|
|
436
569
|
}
|
|
437
570
|
return blocks;
|
|
438
571
|
}
|
|
@@ -460,6 +593,131 @@ function json(body, status = 200) {
|
|
|
460
593
|
function text(body, status = 200) {
|
|
461
594
|
return { kind: "text", status, body };
|
|
462
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
|
+
}
|
|
463
721
|
function createSlackEventProcessor(input) {
|
|
464
722
|
async function processBlockActions(payload, slackApp) {
|
|
465
723
|
const action = payload.actions?.find((candidate) => {
|
|
@@ -528,7 +786,7 @@ function createSlackEventProcessor(input) {
|
|
|
528
786
|
return json({ ok: true });
|
|
529
787
|
}
|
|
530
788
|
return {
|
|
531
|
-
async process(payload, slackApp) {
|
|
789
|
+
async process(payload, slackApp, verification = {}) {
|
|
532
790
|
if (payload.type === "block_actions") {
|
|
533
791
|
return processBlockActions(payload, slackApp);
|
|
534
792
|
}
|
|
@@ -545,6 +803,177 @@ function createSlackEventProcessor(input) {
|
|
|
545
803
|
return json({ error: "invalid_event_payload" }, 400);
|
|
546
804
|
}
|
|
547
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
|
+
}
|
|
548
977
|
if (payload.event.type === "message" && (!rawThreadActionText || !parseThreadActionCommand(rawThreadActionText))) {
|
|
549
978
|
return json({ ok: true });
|
|
550
979
|
}
|
|
@@ -578,8 +1007,11 @@ function createSlackEventProcessor(input) {
|
|
|
578
1007
|
teamId: payload.team_id,
|
|
579
1008
|
channelId: payload.event.channel,
|
|
580
1009
|
messageTs: payload.event.ts,
|
|
1010
|
+
sourceDeliveryId: payload.event_id,
|
|
1011
|
+
slackEventId: payload.event_id,
|
|
581
1012
|
...payload.api_app_id ? { slackAppId: payload.api_app_id } : {},
|
|
582
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" } : {},
|
|
583
1015
|
repoProvider: binding.repoProvider ?? "github",
|
|
584
1016
|
owner: binding.owner,
|
|
585
1017
|
repo: binding.repo
|
|
@@ -603,7 +1035,8 @@ function createSlackEventProcessor(input) {
|
|
|
603
1035
|
...payload.api_app_id ? { appId: payload.api_app_id } : {},
|
|
604
1036
|
...payload.event.thread_ts ? { threadTs: payload.event.thread_ts } : {},
|
|
605
1037
|
...payload.authorizations?.[0]?.user_id ? { botUserId: payload.authorizations[0].user_id } : {},
|
|
606
|
-
...slackApp.callbackUri ? { callbackUri: slackApp.callbackUri } : {}
|
|
1038
|
+
...slackApp.callbackUri ? { callbackUri: slackApp.callbackUri } : {},
|
|
1039
|
+
...typeof verification.signatureVerified === "boolean" ? { signatureVerified: verification.signatureVerified } : {}
|
|
607
1040
|
});
|
|
608
1041
|
if (!event) {
|
|
609
1042
|
return json({ ok: true, ignored: "empty_command" });
|
|
@@ -617,17 +1050,128 @@ function createSlackEventProcessor(input) {
|
|
|
617
1050
|
// src/ingress.ts
|
|
618
1051
|
import { createHmac, timingSafeEqual } from "crypto";
|
|
619
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";
|
|
620
1055
|
import { Hono } from "hono";
|
|
621
1056
|
|
|
622
1057
|
// src/dispatcher-events.ts
|
|
623
1058
|
import { randomUUID } from "crypto";
|
|
624
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
|
+
}
|
|
625
1167
|
function createSlackDispatcherEventProcessorInput(config) {
|
|
1168
|
+
const fetchImpl = config.fetchImpl ?? fetch;
|
|
626
1169
|
const dispatcherClient = createOpenTagClient({
|
|
627
1170
|
dispatcherUrl: config.dispatcherUrl,
|
|
628
|
-
...config.dispatcherToken ? { pairingToken: config.dispatcherToken } : {}
|
|
1171
|
+
...config.dispatcherToken ? { pairingToken: config.dispatcherToken } : {},
|
|
1172
|
+
fetchImpl
|
|
629
1173
|
});
|
|
630
|
-
|
|
1174
|
+
const processorInput = {
|
|
631
1175
|
async resolveChannelBinding(input) {
|
|
632
1176
|
try {
|
|
633
1177
|
const { binding } = await dispatcherClient.getChannelBinding({
|
|
@@ -654,11 +1198,112 @@ function createSlackDispatcherEventProcessorInput(config) {
|
|
|
654
1198
|
const created = await dispatcherClient.createRun({ runId, event });
|
|
655
1199
|
return created.outcome === "run_created" ? { runId: created.run.id } : { runId };
|
|
656
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
|
+
},
|
|
657
1211
|
async submitThreadAction(action) {
|
|
658
1212
|
await dispatcherClient.submitThreadAction(action);
|
|
659
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
|
+
},
|
|
660
1278
|
now: () => (/* @__PURE__ */ new Date()).toISOString()
|
|
661
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;
|
|
662
1307
|
}
|
|
663
1308
|
|
|
664
1309
|
// src/ingress.ts
|
|
@@ -680,9 +1325,94 @@ function verifySlackTimestamp(input) {
|
|
|
680
1325
|
const ageSeconds = Math.abs(Math.floor(input.nowMs / 1e3) - timestampSeconds);
|
|
681
1326
|
return ageSeconds <= toleranceSeconds;
|
|
682
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
|
+
}
|
|
683
1412
|
function createSlackEventsApp(input) {
|
|
684
1413
|
const app = new Hono();
|
|
685
1414
|
const processor = createSlackEventProcessor(input);
|
|
1415
|
+
const maxRequestBodyBytes = input.maxRequestBodyBytes ?? DEFAULT_MAX_REQUEST_BODY_BYTES;
|
|
686
1416
|
function parseSlackPayload(rawBody, contentType) {
|
|
687
1417
|
try {
|
|
688
1418
|
if (contentType?.includes("application/x-www-form-urlencoded") || rawBody.startsWith("payload=")) {
|
|
@@ -714,16 +1444,56 @@ function createSlackEventsApp(input) {
|
|
|
714
1444
|
const timestamp = c.req.header("x-slack-request-timestamp");
|
|
715
1445
|
const signature = c.req.header("x-slack-signature");
|
|
716
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
|
+
});
|
|
717
1453
|
return c.json({ error: "missing_signature_headers" }, 401);
|
|
718
1454
|
}
|
|
719
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
|
+
});
|
|
720
1462
|
return c.json({ error: "stale_signature_timestamp" }, 401);
|
|
721
1463
|
}
|
|
722
|
-
|
|
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
|
+
}
|
|
723
1479
|
const payload = parseSlackPayload(rawBody, c.req.header("content-type"));
|
|
724
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
|
+
});
|
|
725
1486
|
return c.json({ error: "invalid_json" }, 400);
|
|
726
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
|
+
}
|
|
727
1497
|
const resolvedSlackApp = resolveSlackApp({
|
|
728
1498
|
rawBody,
|
|
729
1499
|
signature,
|
|
@@ -731,9 +1501,16 @@ function createSlackEventsApp(input) {
|
|
|
731
1501
|
...payload.api_app_id ? { apiAppId: payload.api_app_id } : {}
|
|
732
1502
|
});
|
|
733
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
|
+
});
|
|
734
1511
|
return c.json({ error: resolvedSlackApp.error }, 401);
|
|
735
1512
|
}
|
|
736
|
-
const result = await processor.process(payload, resolvedSlackApp.slackApp);
|
|
1513
|
+
const result = await processor.process(payload, resolvedSlackApp.slackApp, { signatureVerified: true });
|
|
737
1514
|
if (result.kind === "text") {
|
|
738
1515
|
return c.text(result.body, result.status);
|
|
739
1516
|
}
|
|
@@ -743,6 +1520,10 @@ function createSlackEventsApp(input) {
|
|
|
743
1520
|
}
|
|
744
1521
|
function startSlackIngress(config) {
|
|
745
1522
|
const port = config.port ?? 3040;
|
|
1523
|
+
const dispatcherClient = createOpenTagClient2({
|
|
1524
|
+
dispatcherUrl: config.dispatcherUrl,
|
|
1525
|
+
...config.dispatcherToken ? { pairingToken: config.dispatcherToken } : {}
|
|
1526
|
+
});
|
|
746
1527
|
const server = serve({
|
|
747
1528
|
fetch: createSlackEventsApp({
|
|
748
1529
|
slackApps: [
|
|
@@ -753,6 +1534,10 @@ function startSlackIngress(config) {
|
|
|
753
1534
|
...config.callbackUri ? { callbackUri: config.callbackUri } : {}
|
|
754
1535
|
}
|
|
755
1536
|
],
|
|
1537
|
+
...config.maxRequestBodyBytes ? { maxRequestBodyBytes: config.maxRequestBodyBytes } : {},
|
|
1538
|
+
async recordControlPlaneEvent(event) {
|
|
1539
|
+
await dispatcherClient.recordControlPlaneEvent(event);
|
|
1540
|
+
},
|
|
756
1541
|
...createSlackDispatcherEventProcessorInput(config)
|
|
757
1542
|
}).fetch,
|
|
758
1543
|
port
|
|
@@ -776,6 +1561,7 @@ function startSlackIngress(config) {
|
|
|
776
1561
|
|
|
777
1562
|
// src/socket-mode.ts
|
|
778
1563
|
import WebSocket from "ws";
|
|
1564
|
+
import { createOpenTagClient as createOpenTagClient3 } from "@opentag/client";
|
|
779
1565
|
var SLACK_CONNECTIONS_OPEN_URL = "https://slack.com/api/apps.connections.open";
|
|
780
1566
|
var DEFAULT_RECONNECT_DELAY_MS = 1e3;
|
|
781
1567
|
var TERMINAL_SLACK_ERROR_CODES = [
|
|
@@ -789,9 +1575,9 @@ var TERMINAL_SLACK_ERROR_CODES = [
|
|
|
789
1575
|
"missing_scope",
|
|
790
1576
|
"ekm_access_denied"
|
|
791
1577
|
];
|
|
792
|
-
function
|
|
793
|
-
if (!(error instanceof Error)) return
|
|
794
|
-
return TERMINAL_SLACK_ERROR_CODES.
|
|
1578
|
+
function terminalSlackAuthErrorCode(error) {
|
|
1579
|
+
if (!(error instanceof Error)) return null;
|
|
1580
|
+
return TERMINAL_SLACK_ERROR_CODES.find((code) => error.message.includes(code)) ?? null;
|
|
795
1581
|
}
|
|
796
1582
|
function rawDataToString(data) {
|
|
797
1583
|
if (typeof data === "string") return data;
|
|
@@ -842,6 +1628,25 @@ async function handleSocketMessage(input) {
|
|
|
842
1628
|
function wait(ms) {
|
|
843
1629
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
844
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
|
+
}
|
|
845
1650
|
function startSlackSocketModeApp(input, dependencies = {}) {
|
|
846
1651
|
const fetchImpl = dependencies.fetchImpl ?? fetch;
|
|
847
1652
|
const createWebSocket = dependencies.createWebSocket ?? ((url) => new WebSocket(url));
|
|
@@ -892,9 +1697,15 @@ function startSlackSocketModeApp(input, dependencies = {}) {
|
|
|
892
1697
|
const socketUrl = await openSlackSocketUrl({ appToken: input.appToken, fetchImpl });
|
|
893
1698
|
await runOneConnection(socketUrl);
|
|
894
1699
|
} catch (error) {
|
|
895
|
-
|
|
1700
|
+
const terminalErrorCode = terminalSlackAuthErrorCode(error);
|
|
1701
|
+
if (terminalErrorCode) {
|
|
896
1702
|
if (!closed) {
|
|
897
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
|
+
});
|
|
898
1709
|
}
|
|
899
1710
|
throw error;
|
|
900
1711
|
}
|
|
@@ -917,6 +1728,11 @@ function startSlackSocketModeApp(input, dependencies = {}) {
|
|
|
917
1728
|
};
|
|
918
1729
|
}
|
|
919
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
|
+
});
|
|
920
1736
|
return startSlackSocketModeApp(
|
|
921
1737
|
{
|
|
922
1738
|
appToken: config.appToken,
|
|
@@ -925,6 +1741,9 @@ function startSlackSocketModeIngress(config, dependencies = {}) {
|
|
|
925
1741
|
...config.appId ? { appId: config.appId } : {},
|
|
926
1742
|
...config.callbackUri ? { callbackUri: config.callbackUri } : {}
|
|
927
1743
|
},
|
|
1744
|
+
async recordControlPlaneEvent(event) {
|
|
1745
|
+
await dispatcherClient.recordControlPlaneEvent(event);
|
|
1746
|
+
},
|
|
928
1747
|
...createSlackDispatcherEventProcessorInput(config)
|
|
929
1748
|
},
|
|
930
1749
|
dependencies
|
|
@@ -933,11 +1752,15 @@ function startSlackSocketModeIngress(config, dependencies = {}) {
|
|
|
933
1752
|
export {
|
|
934
1753
|
buildSlackSuggestedActionButtonValue,
|
|
935
1754
|
computeSlackSignature,
|
|
1755
|
+
createSlackActionReceiptBlocks,
|
|
1756
|
+
createSlackDoctorSummaryBlocks,
|
|
936
1757
|
createSlackEventProcessor,
|
|
937
1758
|
createSlackEventsApp,
|
|
938
1759
|
createSlackFinalResultBlocks,
|
|
1760
|
+
createSlackFinalSummaryBlocks,
|
|
939
1761
|
createSlackPostMessagePayload,
|
|
940
1762
|
createSlackReactionPayload,
|
|
1763
|
+
createSlackSourceThreadStatusBlocks,
|
|
941
1764
|
createSlackUpdateMessagePayload,
|
|
942
1765
|
encodeSlackThreadKey,
|
|
943
1766
|
markdownToSlackMrkdwn,
|
|
@@ -945,7 +1768,9 @@ export {
|
|
|
945
1768
|
parseSlackSuggestedActionButtonValue,
|
|
946
1769
|
parseSlackThreadKey,
|
|
947
1770
|
renderSlackAcknowledgement,
|
|
1771
|
+
renderSlackActionReceiptPresentation,
|
|
948
1772
|
renderSlackFinalResult,
|
|
1773
|
+
renderSlackFinalSummaryPresentation,
|
|
949
1774
|
slackSourceReceiptReactionName,
|
|
950
1775
|
startSlackIngress,
|
|
951
1776
|
startSlackSocketModeApp,
|