@opentag/slack 0.2.0 → 0.3.1

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