@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/index.js CHANGED
@@ -1,5 +1,11 @@
1
1
  // src/events.ts
2
- import { parseThreadActionCommand } from "@opentag/core";
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 { suggestedActionCandidatesFromResult } from "@opentag/core";
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 renderSuggestedActionCandidateLines(candidate) {
290
- const lines = [`${candidate.index}. *${markdownToSlackMrkdwn(candidate.intent.summary)}*`];
291
- const details = renderSuggestedActionDetails(candidate.intent.params, candidate.intent.action).filter((line) => line.trim().startsWith("Branch:") || line.trim().startsWith("Changed files:")).map((line) => line.replace(/^\s+/, ""));
292
- lines.push(...details);
293
- if (candidate.proposalPreconditions?.length) {
294
- lines.push(`Preconditions: ${candidate.proposalPreconditions.length} check(s) in the audit log.`);
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
- function renderSuggestedActionsMarkdown(result) {
299
- const candidates = suggestedActionCandidatesFromResult(result);
300
- if (candidates.length === 0) return [];
301
- const lines = ["*Suggested actions*"];
302
- const visibleCandidates = candidates.slice(0, MAX_SLACK_SUGGESTED_ACTION_CANDIDATES);
303
- for (const candidate of visibleCandidates) {
304
- lines.push("", ...renderSuggestedActionCandidateLines(candidate));
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 = candidates.length - visibleCandidates.length;
348
+ const remainingCount = input.actions.length - visibleActions.length;
307
349
  if (remainingCount > 0) {
308
- lines.push("", `Showing first ${visibleCandidates.length} of ${candidates.length} actions. Reply with an action number for the rest.`);
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 `apply 1`, `approve 1`, or `reject 1`.");
352
+ lines.push("", "Use the buttons below, or reply with the matching command.");
311
353
  return lines;
312
354
  }
313
- function createSuggestedActionButtons(candidate) {
314
- return [
315
- {
316
- type: "button",
317
- text: { type: "plain_text", text: `Apply ${candidate.index}`, emoji: true },
318
- action_id: `opentag:apply:${candidate.index}`,
319
- value: buildSlackSuggestedActionButtonValue({
320
- version: 1,
321
- command: `apply ${candidate.index}`,
322
- proposalId: candidate.proposalId,
323
- intentId: candidate.intent.intentId
324
- }),
325
- style: "primary"
326
- },
327
- {
328
- type: "button",
329
- text: { type: "plain_text", text: "Approve", emoji: true },
330
- action_id: `opentag:approve:${candidate.index}`,
331
- value: buildSlackSuggestedActionButtonValue({
332
- version: 1,
333
- command: `approve ${candidate.index}`,
334
- proposalId: candidate.proposalId,
335
- intentId: candidate.intent.intentId
336
- })
337
- },
338
- {
339
- type: "button",
340
- text: { type: "plain_text", text: "Reject", emoji: true },
341
- action_id: `opentag:reject:${candidate.index}`,
342
- value: buildSlackSuggestedActionButtonValue({
343
- version: 1,
344
- command: `reject ${candidate.index}`,
345
- proposalId: candidate.proposalId,
346
- intentId: candidate.intent.intentId
347
- }),
348
- style: "danger"
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 renderSlackFinalResult(result) {
353
- const lines = [`*Finished: ${result.conclusion}.*`, markdownToSlackMrkdwn(compactSlackSummary(result.summary))];
354
- if (result.verification?.length) {
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: ${result.verification.slice(0, 3).map((check) => `\`${markdownToSlackMrkdwn(check.command)}\` ${markdownToSlackMrkdwn(check.outcome)}`).join(", ")}`
451
+ `Verified: ${presentation.verification.slice(0, 3).map((check) => `\`${markdownToSlackMrkdwn(check.command)}\` ${markdownToSlackMrkdwn(check.outcome)}`).join(", ")}`
357
452
  );
358
453
  }
359
- const nextAction = nextActionSummary(result);
360
- if (nextAction && !result.suggestedChanges?.length) {
361
- lines.push(`Next: ${markdownToSlackMrkdwn(compactNextAction(nextAction))}`);
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: ${result.conclusion}.*
376
- ${markdownToSlackMrkdwn(compactSlackSummary(result.summary))}`
481
+ text: `*Finished: ${presentation.outcome}.*
482
+ ${markdownToSlackMrkdwn(compactSlackSummary(presentation.summary))}`
377
483
  }
378
484
  }
379
485
  ];
380
- if (result.verification?.length) {
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
- result.verification.slice(0, 3).map((check) => `\`${check.command}\` ${check.outcome}`).join(", ")
492
+ presentation.verification.slice(0, 3).map((check) => `\`${check.command}\` ${check.outcome}`).join(", ")
387
493
  )}`
388
494
  }
389
495
  });
390
496
  }
391
- const nextAction = nextActionSummary(result);
392
- const suggestedActionCandidates = suggestedActionCandidatesFromResult(result);
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(nextAction))}`
503
+ text: `Next: ${markdownToSlackMrkdwn(compactNextAction(presentation.nextActions[0] ?? ""))}`
399
504
  }
400
505
  });
401
506
  }
402
- if (suggestedActionCandidates.length > 0) {
403
- blocks.push({ type: "divider" });
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: "section",
406
- text: {
407
- type: "mrkdwn",
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: `Showing first ${visibleCandidates.length} of ${suggestedActionCandidates.length} actions. Reply with an action number for the rest.`
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
- return {
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
- const rawBody = await c.req.text();
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 isTerminalSlackAuthError(error) {
793
- if (!(error instanceof Error)) return false;
794
- return TERMINAL_SLACK_ERROR_CODES.some((code) => error.message.includes(code));
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
- if (isTerminalSlackAuthError(error)) {
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,