@opentag/slack 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,14 +1,24 @@
1
+ // src/events.ts
2
+ import { parseThreadActionCommand } from "@opentag/core";
3
+
1
4
  // src/normalize.ts
2
5
  import { commandFromRawText } from "@opentag/core";
3
- function stripSlackAppMention(text, botUserId) {
4
- const patterns = botUserId ? [new RegExp(`^<@${botUserId}>\\s*`, "i"), /^<@[^>]+>\s*/] : [/^<@[^>]+>\s*/];
5
- for (const pattern of patterns) {
6
- const stripped = text.replace(pattern, "").trim();
7
- if (stripped !== text.trim()) {
8
- return stripped.length > 0 ? stripped : null;
9
- }
6
+ var LEADING_MENTION_RUN = /^(?:<@[^>]+>\s*)+/;
7
+ var MENTION_TOKEN = /<@([^>]+)>/g;
8
+ function stripSlackAppMention(text2, botUserId) {
9
+ const run = text2.match(LEADING_MENTION_RUN);
10
+ if (!run) return null;
11
+ const leadingRun = run[0];
12
+ if (botUserId) {
13
+ const mentionedIds = leadingRun.match(MENTION_TOKEN) ?? [];
14
+ const botIsMentioned = mentionedIds.some((token) => {
15
+ const id = token.slice(2, -1).split("|")[0] ?? "";
16
+ return id.toLowerCase() === botUserId.toLowerCase();
17
+ });
18
+ if (!botIsMentioned) return null;
10
19
  }
11
- return null;
20
+ const stripped = text2.slice(leadingRun.length).trim();
21
+ return stripped.length > 0 ? stripped : null;
12
22
  }
13
23
  function encodeSlackThreadKey(input) {
14
24
  return `${input.teamId}|${input.channelId}|${input.threadTs}`;
@@ -20,18 +30,27 @@ function parseSlackThreadKey(threadKey) {
20
30
  }
21
31
  return { teamId, channelId, threadTs };
22
32
  }
23
- function permissionsForIntent(intent) {
33
+ 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;
34
+ 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;
35
+ function commandLooksRepoWriteCapable(command) {
36
+ return UNKNOWN_WRITE_VERB_PATTERN.test(command.rawText) && REPO_WRITE_TARGET_PATTERN.test(command.rawText);
37
+ }
38
+ function permissionsForCommand(command) {
24
39
  const permissions = [
25
40
  {
26
41
  scope: "chat:postMessage",
27
42
  reason: "reply in the originating Slack thread"
28
43
  },
44
+ {
45
+ scope: "reactions:write",
46
+ reason: "mark the originating Slack message as received without posting a thread reply"
47
+ },
29
48
  {
30
49
  scope: "runner:local",
31
50
  reason: "execute the run on a paired local daemon"
32
51
  }
33
52
  ];
34
- if (intent === "fix" || intent === "run") {
53
+ if (command.intent === "fix" || command.intent === "run" || command.intent === "unknown" && commandLooksRepoWriteCapable(command)) {
35
54
  permissions.push(
36
55
  {
37
56
  scope: "repo:read",
@@ -49,11 +68,50 @@ function permissionsForIntent(intent) {
49
68
  }
50
69
  return permissions;
51
70
  }
71
+ function contextPointersForCommand(command) {
72
+ const context = [];
73
+ for (const reference of command.parsed?.references ?? []) {
74
+ if (reference.kind === "url") {
75
+ context.push({
76
+ kind: "url",
77
+ uri: reference.uri,
78
+ visibility: "organization",
79
+ title: reference.title ?? "Command URL reference"
80
+ });
81
+ continue;
82
+ }
83
+ if (reference.kind === "file" || reference.kind === "path") {
84
+ context.push({
85
+ kind: "file",
86
+ uri: reference.uri,
87
+ ...reference.line ? { line: reference.line } : {},
88
+ ...reference.startLine ? { startLine: reference.startLine } : {},
89
+ ...reference.endLine ? { endLine: reference.endLine } : {},
90
+ visibility: "organization",
91
+ title: referenceTitle(reference)
92
+ });
93
+ }
94
+ }
95
+ return context;
96
+ }
97
+ function referenceTitle(reference) {
98
+ return reference.title ?? "Command file reference";
99
+ }
100
+ function commandMetadata(command) {
101
+ if (!command.parsed) return {};
102
+ return {
103
+ commandParser: command.parsed.version,
104
+ commandDiagnostics: command.parsed.diagnostics,
105
+ ...command.parsed.approval ? { approval: command.parsed.approval } : {},
106
+ ...command.parsed.network ? { network: command.parsed.network } : {}
107
+ };
108
+ }
52
109
  function normalizeSlackAppMention(input) {
53
110
  const rawText = stripSlackAppMention(input.text, input.botUserId);
54
111
  if (!rawText) return null;
55
112
  const command = commandFromRawText(rawText);
56
113
  const replyThreadTs = input.threadTs ?? input.ts;
114
+ const agentId = input.agentId ?? "opentag";
57
115
  return {
58
116
  id: `evt_slack_app_mention_${input.eventId}`,
59
117
  source: "slack",
@@ -67,12 +125,14 @@ function normalizeSlackAppMention(input) {
67
125
  },
68
126
  target: {
69
127
  mention: input.botUserId ? `<@${input.botUserId}>` : "<@app>",
70
- agentId: "opentag"
128
+ agentId,
129
+ ...command.parsed?.executorHint ? { executorHint: command.parsed.executorHint } : {}
71
130
  },
72
131
  command,
73
132
  context: [
74
133
  {
75
- kind: "url",
134
+ provider: "slack",
135
+ kind: "message",
76
136
  uri: `slack://team/${input.teamId}/channel/${input.channelId}/message/${input.ts}`,
77
137
  visibility: "organization",
78
138
  title: "Slack message"
@@ -82,9 +142,10 @@ function normalizeSlackAppMention(input) {
82
142
  uri: input.text,
83
143
  visibility: "organization",
84
144
  title: "Slack message text"
85
- }
145
+ },
146
+ ...contextPointersForCommand(command)
86
147
  ],
87
- permissions: permissionsForIntent(command.intent),
148
+ permissions: permissionsForCommand(command),
88
149
  callback: {
89
150
  provider: "slack",
90
151
  uri: input.callbackUri ?? "https://slack.com/api/chat.postMessage",
@@ -98,16 +159,799 @@ function normalizeSlackAppMention(input) {
98
159
  teamId: input.teamId,
99
160
  channelId: input.channelId,
100
161
  messageTs: input.ts,
101
- repoProvider: "github",
162
+ ...input.appId ? { slackAppId: input.appId } : {},
163
+ ...input.botUserId ? { slackBotUserId: input.botUserId } : {},
164
+ ...commandMetadata(command),
165
+ repoProvider: input.binding.repoProvider ?? "github",
102
166
  owner: input.binding.owner,
103
167
  repo: input.binding.repo
104
168
  }
105
169
  };
106
170
  }
171
+
172
+ // src/render.ts
173
+ import { suggestedActionCandidatesFromResult } from "@opentag/core";
174
+ var MAX_SLACK_SUGGESTED_ACTION_CANDIDATES = 20;
175
+ function buildSlackSuggestedActionButtonValue(input) {
176
+ return JSON.stringify(input);
177
+ }
178
+ function parseSlackSuggestedActionButtonValue(value) {
179
+ try {
180
+ const parsed = JSON.parse(value);
181
+ 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) {
182
+ return null;
183
+ }
184
+ return {
185
+ version: 1,
186
+ command: parsed.command.trim(),
187
+ proposalId: parsed.proposalId,
188
+ intentId: parsed.intentId
189
+ };
190
+ } catch {
191
+ return null;
192
+ }
193
+ }
194
+ function escapeSlackText(text2) {
195
+ return text2.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
196
+ }
197
+ function markdownToSlackMrkdwn(text2) {
198
+ const links = [];
199
+ const withoutLinks = text2.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match, label, url) => {
200
+ const token = `\0SLACK_LINK_${links.length}\0`;
201
+ links.push(`<${url}|${escapeSlackText(label)}>`);
202
+ return token;
203
+ });
204
+ const converted = escapeSlackText(withoutLinks).replace(/\*\*(.+?)\*\*/g, "*$1*").replace(/__(.+?)__/g, "*$1*");
205
+ return links.reduce((output, link, index) => output.replace(`\0SLACK_LINK_${index}\0`, link), converted);
206
+ }
207
+ function renderSlackAcknowledgement(runId) {
208
+ void runId;
209
+ return "Working on it.";
210
+ }
211
+ function slackSourceReceiptReactionName(state) {
212
+ if (state === "received") return "eyes";
213
+ return "eyes";
214
+ }
215
+ function createSlackReactionPayload(input) {
216
+ return {
217
+ channel: input.channelId,
218
+ timestamp: input.messageTs,
219
+ name: input.name
220
+ };
221
+ }
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
+ function truncateSlackText(text2, maxLength) {
271
+ const normalized = text2.replace(/\s+/g, " ").trim();
272
+ if (normalized.length <= maxLength) return normalized;
273
+ return `${normalized.slice(0, Math.max(0, maxLength - 1)).trimEnd()}\u2026`;
274
+ }
275
+ function firstMarkdownSection(text2, heading) {
276
+ const pattern = new RegExp(`\\*\\*${heading}:\\*\\*\\s*([\\s\\S]*?)(?=\\n\\s*\\n\\*\\*[^*]+:\\*\\*|\\n\\s*\\n[A-Z][^\\n]{0,60}:|$)`, "i");
277
+ const match = text2.match(pattern);
278
+ return match?.[1]?.trim();
279
+ }
280
+ function compactSlackSummary(summary) {
281
+ const whatChanged = firstMarkdownSection(summary, "What changed");
282
+ const firstParagraph = summary.split(/\n\s*\n/).map((part) => part.trim()).find(Boolean);
283
+ const selected = whatChanged ?? firstParagraph ?? summary;
284
+ return truncateSlackText(selected.replace(/^\*\*[^*]+:\*\*\s*/i, ""), 360);
285
+ }
286
+ function compactNextAction(nextAction) {
287
+ return truncateSlackText(nextAction, 180);
288
+ }
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.`);
295
+ }
296
+ return lines;
297
+ }
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));
305
+ }
306
+ const remainingCount = candidates.length - visibleCandidates.length;
307
+ if (remainingCount > 0) {
308
+ lines.push("", `Showing first ${visibleCandidates.length} of ${candidates.length} actions. Reply with an action number for the rest.`);
309
+ }
310
+ lines.push("", "Use the buttons below, or reply `apply 1`, `approve 1`, or `reject 1`.");
311
+ return lines;
312
+ }
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"
349
+ }
350
+ ];
351
+ }
352
+ function renderSlackFinalResult(result) {
353
+ const lines = [`*Finished: ${result.conclusion}.*`, markdownToSlackMrkdwn(compactSlackSummary(result.summary))];
354
+ if (result.verification?.length) {
355
+ lines.push(
356
+ `Verified: ${result.verification.slice(0, 3).map((check) => `\`${markdownToSlackMrkdwn(check.command)}\` ${markdownToSlackMrkdwn(check.outcome)}`).join(", ")}`
357
+ );
358
+ }
359
+ const nextAction = nextActionSummary(result);
360
+ if (nextAction && !result.suggestedChanges?.length) {
361
+ lines.push(`Next: ${markdownToSlackMrkdwn(compactNextAction(nextAction))}`);
362
+ }
363
+ const suggestedActions = renderSuggestedActionsMarkdown(result);
364
+ if (suggestedActions.length > 0) {
365
+ lines.push("", ...suggestedActions);
366
+ }
367
+ return lines.join("\n");
368
+ }
369
+ function createSlackFinalResultBlocks(result) {
370
+ const blocks = [
371
+ {
372
+ type: "section",
373
+ text: {
374
+ type: "mrkdwn",
375
+ text: `*Finished: ${result.conclusion}.*
376
+ ${markdownToSlackMrkdwn(compactSlackSummary(result.summary))}`
377
+ }
378
+ }
379
+ ];
380
+ if (result.verification?.length) {
381
+ blocks.push({
382
+ type: "section",
383
+ text: {
384
+ type: "mrkdwn",
385
+ text: `Verified: ${markdownToSlackMrkdwn(
386
+ result.verification.slice(0, 3).map((check) => `\`${check.command}\` ${check.outcome}`).join(", ")
387
+ )}`
388
+ }
389
+ });
390
+ }
391
+ const nextAction = nextActionSummary(result);
392
+ const suggestedActionCandidates = suggestedActionCandidatesFromResult(result);
393
+ if (nextAction && suggestedActionCandidates.length === 0) {
394
+ blocks.push({
395
+ type: "section",
396
+ text: {
397
+ type: "mrkdwn",
398
+ text: `Next: ${markdownToSlackMrkdwn(compactNextAction(nextAction))}`
399
+ }
400
+ });
401
+ }
402
+ if (suggestedActionCandidates.length > 0) {
403
+ blocks.push({ type: "divider" });
404
+ 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: {
431
+ type: "mrkdwn",
432
+ text: `Showing first ${visibleCandidates.length} of ${suggestedActionCandidates.length} actions. Reply with an action number for the rest.`
433
+ }
434
+ });
435
+ }
436
+ }
437
+ return blocks;
438
+ }
439
+ function createSlackPostMessagePayload(input) {
440
+ return {
441
+ channel: input.channelId,
442
+ text: markdownToSlackMrkdwn(input.text),
443
+ thread_ts: input.threadTs,
444
+ ...input.blocks?.length ? { blocks: input.blocks } : {}
445
+ };
446
+ }
447
+ function createSlackUpdateMessagePayload(input) {
448
+ return {
449
+ channel: input.channelId,
450
+ text: markdownToSlackMrkdwn(input.text),
451
+ ts: input.messageTs,
452
+ ...input.blocks?.length ? { blocks: input.blocks } : {}
453
+ };
454
+ }
455
+
456
+ // src/events.ts
457
+ function json(body, status = 200) {
458
+ return { kind: "json", status, body };
459
+ }
460
+ function text(body, status = 200) {
461
+ return { kind: "text", status, body };
462
+ }
463
+ function createSlackEventProcessor(input) {
464
+ async function processBlockActions(payload, slackApp) {
465
+ const action = payload.actions?.find((candidate) => {
466
+ if (candidate.action_id?.startsWith("opentag:")) return true;
467
+ return typeof candidate.value === "string" && parseSlackSuggestedActionButtonValue(candidate.value) !== null;
468
+ });
469
+ if (!action) {
470
+ return json({ ok: true });
471
+ }
472
+ const parsedValue = typeof action.value === "string" ? parseSlackSuggestedActionButtonValue(action.value) : null;
473
+ const rawText = parsedValue?.command ?? (typeof action.value === "string" && parseThreadActionCommand(action.value) ? action.value.trim() : void 0);
474
+ if (!rawText || !parseThreadActionCommand(rawText)) {
475
+ return json({ error: "invalid_interactive_action" }, 400);
476
+ }
477
+ if (!input.submitThreadAction) {
478
+ return json({ ok: true });
479
+ }
480
+ const teamId = payload.team?.id;
481
+ const userId = payload.user?.id;
482
+ const channelId = payload.channel?.id ?? payload.container?.channel_id;
483
+ const messageTs = payload.message?.ts ?? payload.container?.message_ts;
484
+ const threadTs = payload.message?.thread_ts ?? payload.container?.thread_ts ?? messageTs;
485
+ if (!teamId || !userId || !channelId || !messageTs || !threadTs) {
486
+ return json({ error: "invalid_interactive_payload" }, 400);
487
+ }
488
+ const binding = await input.resolveChannelBinding({
489
+ teamId,
490
+ channelId
491
+ });
492
+ if (!binding) {
493
+ return json({ ok: true, ignored: "unbound_channel" });
494
+ }
495
+ await input.submitThreadAction({
496
+ id: `approval_slack_block_${payload.trigger_id ?? `${action.action_id ?? "action"}_${action.action_ts ?? messageTs}`}`,
497
+ rawText,
498
+ actor: {
499
+ provider: "slack",
500
+ providerUserId: userId,
501
+ handle: payload.user?.username ?? payload.user?.name ?? userId,
502
+ organizationId: teamId
503
+ },
504
+ callback: {
505
+ provider: "slack",
506
+ uri: slackApp.callbackUri ?? "https://slack.com/api/chat.postMessage",
507
+ threadKey: encodeSlackThreadKey({
508
+ teamId,
509
+ channelId,
510
+ threadTs
511
+ })
512
+ },
513
+ metadata: {
514
+ source: "slack_button",
515
+ teamId,
516
+ channelId,
517
+ messageTs,
518
+ ...payload.api_app_id ? { slackAppId: payload.api_app_id } : {},
519
+ ...action.action_id ? { actionId: action.action_id } : {},
520
+ ...action.block_id ? { blockId: action.block_id } : {},
521
+ ...action.action_ts ? { actionTs: action.action_ts } : {},
522
+ ...parsedValue ? { proposalId: parsedValue.proposalId, intentId: parsedValue.intentId } : {},
523
+ repoProvider: binding.repoProvider ?? "github",
524
+ owner: binding.owner,
525
+ repo: binding.repo
526
+ }
527
+ });
528
+ return json({ ok: true });
529
+ }
530
+ return {
531
+ async process(payload, slackApp) {
532
+ if (payload.type === "block_actions") {
533
+ return processBlockActions(payload, slackApp);
534
+ }
535
+ if (payload.type === "url_verification") {
536
+ return text(payload.challenge ?? "");
537
+ }
538
+ if (payload.type !== "event_callback" || !payload.event || !["app_mention", "message"].includes(payload.event.type)) {
539
+ return json({ ok: true });
540
+ }
541
+ if (payload.event.type === "message" && (payload.event.subtype || payload.event.bot_id)) {
542
+ return json({ ok: true });
543
+ }
544
+ if (!payload.team_id || !payload.event.channel || !payload.event.user || !payload.event.text || !payload.event.ts || !payload.event_id) {
545
+ return json({ error: "invalid_event_payload" }, 400);
546
+ }
547
+ const rawThreadActionText = payload.event.type === "app_mention" ? stripSlackAppMention(payload.event.text, payload.authorizations?.[0]?.user_id) : payload.event.text.trim();
548
+ if (payload.event.type === "message" && (!rawThreadActionText || !parseThreadActionCommand(rawThreadActionText))) {
549
+ return json({ ok: true });
550
+ }
551
+ const binding = await input.resolveChannelBinding({
552
+ teamId: payload.team_id,
553
+ channelId: payload.event.channel
554
+ });
555
+ if (!binding) {
556
+ return json({ ok: true, ignored: "unbound_channel" });
557
+ }
558
+ if (rawThreadActionText && parseThreadActionCommand(rawThreadActionText) && input.submitThreadAction) {
559
+ await input.submitThreadAction({
560
+ id: `approval_slack_${payload.event_id}`,
561
+ rawText: rawThreadActionText,
562
+ actor: {
563
+ provider: "slack",
564
+ providerUserId: payload.event.user,
565
+ handle: payload.event.user,
566
+ organizationId: payload.team_id
567
+ },
568
+ callback: {
569
+ provider: "slack",
570
+ uri: slackApp.callbackUri ?? "https://slack.com/api/chat.postMessage",
571
+ threadKey: encodeSlackThreadKey({
572
+ teamId: payload.team_id,
573
+ channelId: payload.event.channel,
574
+ threadTs: payload.event.thread_ts ?? payload.event.ts
575
+ })
576
+ },
577
+ metadata: {
578
+ teamId: payload.team_id,
579
+ channelId: payload.event.channel,
580
+ messageTs: payload.event.ts,
581
+ ...payload.api_app_id ? { slackAppId: payload.api_app_id } : {},
582
+ ...payload.authorizations?.[0]?.user_id ? { slackBotUserId: payload.authorizations[0].user_id } : {},
583
+ repoProvider: binding.repoProvider ?? "github",
584
+ owner: binding.owner,
585
+ repo: binding.repo
586
+ }
587
+ });
588
+ return json({ ok: true });
589
+ }
590
+ if (payload.event.type !== "app_mention") {
591
+ return json({ ok: true });
592
+ }
593
+ const event = normalizeSlackAppMention({
594
+ teamId: payload.team_id,
595
+ channelId: payload.event.channel,
596
+ userId: payload.event.user,
597
+ text: payload.event.text,
598
+ ts: payload.event.ts,
599
+ eventId: payload.event_id,
600
+ eventTime: payload.event_time ?? Math.floor(Date.parse(input.now()) / 1e3),
601
+ agentId: slackApp.agentId,
602
+ binding,
603
+ ...payload.api_app_id ? { appId: payload.api_app_id } : {},
604
+ ...payload.event.thread_ts ? { threadTs: payload.event.thread_ts } : {},
605
+ ...payload.authorizations?.[0]?.user_id ? { botUserId: payload.authorizations[0].user_id } : {},
606
+ ...slackApp.callbackUri ? { callbackUri: slackApp.callbackUri } : {}
607
+ });
608
+ if (!event) {
609
+ return json({ ok: true, ignored: "empty_command" });
610
+ }
611
+ await input.createRun(event);
612
+ return json({ ok: true });
613
+ }
614
+ };
615
+ }
616
+
617
+ // src/ingress.ts
618
+ import { createHmac, timingSafeEqual } from "crypto";
619
+ import { serve } from "@hono/node-server";
620
+ import { Hono } from "hono";
621
+
622
+ // src/dispatcher-events.ts
623
+ import { randomUUID } from "crypto";
624
+ import { createOpenTagClient } from "@opentag/client";
625
+ function createSlackDispatcherEventProcessorInput(config) {
626
+ const dispatcherClient = createOpenTagClient({
627
+ dispatcherUrl: config.dispatcherUrl,
628
+ ...config.dispatcherToken ? { pairingToken: config.dispatcherToken } : {}
629
+ });
630
+ return {
631
+ async resolveChannelBinding(input) {
632
+ try {
633
+ const { binding } = await dispatcherClient.getChannelBinding({
634
+ provider: "slack",
635
+ accountId: input.teamId,
636
+ conversationId: input.channelId
637
+ });
638
+ return {
639
+ teamId: binding.accountId,
640
+ channelId: binding.conversationId,
641
+ repoProvider: binding.repoProvider,
642
+ owner: binding.owner,
643
+ repo: binding.repo
644
+ };
645
+ } catch (error) {
646
+ if (error instanceof Error && error.message.includes("channel_binding_not_found")) {
647
+ return null;
648
+ }
649
+ throw error;
650
+ }
651
+ },
652
+ async createRun(event) {
653
+ const runId = `run_${randomUUID()}`;
654
+ const created = await dispatcherClient.createRun({ runId, event });
655
+ return created.outcome === "run_created" ? { runId: created.run.id } : { runId };
656
+ },
657
+ async submitThreadAction(action) {
658
+ await dispatcherClient.submitThreadAction(action);
659
+ },
660
+ now: () => (/* @__PURE__ */ new Date()).toISOString()
661
+ };
662
+ }
663
+
664
+ // src/ingress.ts
665
+ function computeSlackSignature(input) {
666
+ const base = `v0:${input.timestamp}:${input.rawBody}`;
667
+ const digest = createHmac("sha256", input.signingSecret).update(base).digest("hex");
668
+ return `v0=${digest}`;
669
+ }
670
+ function verifySlackSignature(input) {
671
+ const expected = computeSlackSignature(input);
672
+ const expectedBuffer = Buffer.from(expected);
673
+ const actualBuffer = Buffer.from(input.signature);
674
+ return expectedBuffer.length === actualBuffer.length && timingSafeEqual(expectedBuffer, actualBuffer);
675
+ }
676
+ function verifySlackTimestamp(input) {
677
+ const timestampSeconds = Number(input.timestamp);
678
+ if (!Number.isFinite(timestampSeconds)) return false;
679
+ const toleranceSeconds = input.toleranceSeconds ?? 300;
680
+ const ageSeconds = Math.abs(Math.floor(input.nowMs / 1e3) - timestampSeconds);
681
+ return ageSeconds <= toleranceSeconds;
682
+ }
683
+ function createSlackEventsApp(input) {
684
+ const app = new Hono();
685
+ const processor = createSlackEventProcessor(input);
686
+ function parseSlackPayload(rawBody, contentType) {
687
+ try {
688
+ if (contentType?.includes("application/x-www-form-urlencoded") || rawBody.startsWith("payload=")) {
689
+ const interactivePayload = new URLSearchParams(rawBody).get("payload");
690
+ if (!interactivePayload) return null;
691
+ return JSON.parse(interactivePayload);
692
+ }
693
+ return JSON.parse(rawBody);
694
+ } catch {
695
+ return null;
696
+ }
697
+ }
698
+ function resolveSlackApp(inputValue) {
699
+ const candidates = inputValue.apiAppId ? input.slackApps.filter((candidate) => !candidate.appId || candidate.appId === inputValue.apiAppId) : input.slackApps;
700
+ if (candidates.length === 0) {
701
+ return { error: "unknown_slack_app" };
702
+ }
703
+ const slackApp = candidates.find(
704
+ (candidate) => verifySlackSignature({
705
+ signingSecret: candidate.signingSecret,
706
+ timestamp: inputValue.timestamp,
707
+ rawBody: inputValue.rawBody,
708
+ signature: inputValue.signature
709
+ })
710
+ );
711
+ return slackApp ? { slackApp } : { error: "invalid_signature" };
712
+ }
713
+ app.post("/slack/events", async (c) => {
714
+ const timestamp = c.req.header("x-slack-request-timestamp");
715
+ const signature = c.req.header("x-slack-signature");
716
+ if (!timestamp || !signature) {
717
+ return c.json({ error: "missing_signature_headers" }, 401);
718
+ }
719
+ if (!verifySlackTimestamp({ timestamp, nowMs: input.clock?.() ?? Date.now() })) {
720
+ return c.json({ error: "stale_signature_timestamp" }, 401);
721
+ }
722
+ const rawBody = await c.req.text();
723
+ const payload = parseSlackPayload(rawBody, c.req.header("content-type"));
724
+ if (!payload) {
725
+ return c.json({ error: "invalid_json" }, 400);
726
+ }
727
+ const resolvedSlackApp = resolveSlackApp({
728
+ rawBody,
729
+ signature,
730
+ timestamp,
731
+ ...payload.api_app_id ? { apiAppId: payload.api_app_id } : {}
732
+ });
733
+ if ("error" in resolvedSlackApp) {
734
+ return c.json({ error: resolvedSlackApp.error }, 401);
735
+ }
736
+ const result = await processor.process(payload, resolvedSlackApp.slackApp);
737
+ if (result.kind === "text") {
738
+ return c.text(result.body, result.status);
739
+ }
740
+ return c.json(result.body, result.status);
741
+ });
742
+ return app;
743
+ }
744
+ function startSlackIngress(config) {
745
+ const port = config.port ?? 3040;
746
+ const server = serve({
747
+ fetch: createSlackEventsApp({
748
+ slackApps: [
749
+ {
750
+ signingSecret: config.signingSecret,
751
+ agentId: config.agentId ?? "opentag",
752
+ ...config.appId ? { appId: config.appId } : {},
753
+ ...config.callbackUri ? { callbackUri: config.callbackUri } : {}
754
+ }
755
+ ],
756
+ ...createSlackDispatcherEventProcessorInput(config)
757
+ }).fetch,
758
+ port
759
+ });
760
+ return {
761
+ url: `http://localhost:${port}`,
762
+ server,
763
+ close() {
764
+ return new Promise((resolve, reject) => {
765
+ server.close((error) => {
766
+ if (error) {
767
+ reject(error);
768
+ return;
769
+ }
770
+ resolve();
771
+ });
772
+ });
773
+ }
774
+ };
775
+ }
776
+
777
+ // src/socket-mode.ts
778
+ import WebSocket from "ws";
779
+ var SLACK_CONNECTIONS_OPEN_URL = "https://slack.com/api/apps.connections.open";
780
+ var DEFAULT_RECONNECT_DELAY_MS = 1e3;
781
+ var TERMINAL_SLACK_ERROR_CODES = [
782
+ "invalid_auth",
783
+ "not_authed",
784
+ "account_inactive",
785
+ "token_revoked",
786
+ "token_expired",
787
+ "not_allowed_token_type",
788
+ "no_permission",
789
+ "missing_scope",
790
+ "ekm_access_denied"
791
+ ];
792
+ function isTerminalSlackAuthError(error) {
793
+ if (!(error instanceof Error)) return false;
794
+ return TERMINAL_SLACK_ERROR_CODES.some((code) => error.message.includes(code));
795
+ }
796
+ function rawDataToString(data) {
797
+ if (typeof data === "string") return data;
798
+ if (Buffer.isBuffer(data)) return data.toString("utf8");
799
+ if (Array.isArray(data)) return Buffer.concat(data).toString("utf8");
800
+ if (data instanceof ArrayBuffer) return Buffer.from(data).toString("utf8");
801
+ return Buffer.from(data).toString("utf8");
802
+ }
803
+ async function openSlackSocketUrl(input) {
804
+ const response = await input.fetchImpl(SLACK_CONNECTIONS_OPEN_URL, {
805
+ method: "POST",
806
+ headers: {
807
+ authorization: `Bearer ${input.appToken}`
808
+ }
809
+ });
810
+ const body = await response.json().catch(() => ({}));
811
+ if (!response.ok || !body.ok || !body.url) {
812
+ const reason = body.error ?? `http_${response.status}`;
813
+ throw new Error(`Slack Socket Mode connection failed: ${reason}`);
814
+ }
815
+ return body.url;
816
+ }
817
+ function parseSocketEnvelope(data) {
818
+ try {
819
+ return JSON.parse(rawDataToString(data));
820
+ } catch {
821
+ return null;
822
+ }
823
+ }
824
+ async function handleSocketMessage(input) {
825
+ const envelope = parseSocketEnvelope(input.data);
826
+ if (!envelope?.envelope_id) {
827
+ input.logError("[slack] ignored Socket Mode envelope without envelope_id");
828
+ return;
829
+ }
830
+ input.socket.send(JSON.stringify({ envelope_id: envelope.envelope_id }));
831
+ if (!envelope.payload) {
832
+ return;
833
+ }
834
+ if (input.slackApp.appId && envelope.payload.api_app_id && envelope.payload.api_app_id !== input.slackApp.appId) {
835
+ return;
836
+ }
837
+ if (envelope.type !== "events_api" && envelope.payload.type !== "block_actions") {
838
+ return;
839
+ }
840
+ await input.processor.process(envelope.payload, input.slackApp);
841
+ }
842
+ function wait(ms) {
843
+ return new Promise((resolve) => setTimeout(resolve, ms));
844
+ }
845
+ function startSlackSocketModeApp(input, dependencies = {}) {
846
+ const fetchImpl = dependencies.fetchImpl ?? fetch;
847
+ const createWebSocket = dependencies.createWebSocket ?? ((url) => new WebSocket(url));
848
+ const reconnectDelayMs = dependencies.reconnectDelayMs ?? DEFAULT_RECONNECT_DELAY_MS;
849
+ const log = dependencies.log ?? ((message) => console.log(message));
850
+ const logError = dependencies.logError ?? ((message, error) => error ? console.error(message, error) : console.error(message));
851
+ const processor = createSlackEventProcessor(input);
852
+ let closed = false;
853
+ let activeSocket;
854
+ async function runOneConnection(socketUrl) {
855
+ await new Promise((resolve) => {
856
+ const socket = createWebSocket(socketUrl);
857
+ activeSocket = socket;
858
+ let settled = false;
859
+ const finish = () => {
860
+ if (settled) return;
861
+ settled = true;
862
+ if (activeSocket === socket) activeSocket = void 0;
863
+ resolve();
864
+ };
865
+ socket.once("open", () => {
866
+ log("[slack] Socket Mode connected");
867
+ });
868
+ socket.on("message", (data) => {
869
+ void handleSocketMessage({
870
+ data,
871
+ socket,
872
+ processor,
873
+ slackApp: input.slackApp,
874
+ logError
875
+ }).catch((error) => {
876
+ logError("[slack] failed to handle Socket Mode event:", error);
877
+ });
878
+ });
879
+ socket.once("close", finish);
880
+ socket.once("error", (error) => {
881
+ if (!closed) {
882
+ logError("[slack] Socket Mode connection error:", error);
883
+ }
884
+ socket.close();
885
+ finish();
886
+ });
887
+ });
888
+ }
889
+ const startPromise = (async () => {
890
+ while (!closed) {
891
+ try {
892
+ const socketUrl = await openSlackSocketUrl({ appToken: input.appToken, fetchImpl });
893
+ await runOneConnection(socketUrl);
894
+ } catch (error) {
895
+ if (isTerminalSlackAuthError(error)) {
896
+ if (!closed) {
897
+ logError("[slack] terminal Socket Mode auth/config error, aborting:", error);
898
+ }
899
+ throw error;
900
+ }
901
+ if (!closed) {
902
+ logError("[slack] failed to open Socket Mode connection, retrying:", error);
903
+ }
904
+ }
905
+ if (!closed) {
906
+ await wait(reconnectDelayMs);
907
+ }
908
+ }
909
+ })();
910
+ return {
911
+ startPromise,
912
+ async close() {
913
+ closed = true;
914
+ activeSocket?.close();
915
+ await startPromise.catch(() => void 0);
916
+ }
917
+ };
918
+ }
919
+ function startSlackSocketModeIngress(config, dependencies = {}) {
920
+ return startSlackSocketModeApp(
921
+ {
922
+ appToken: config.appToken,
923
+ slackApp: {
924
+ agentId: config.agentId ?? "opentag",
925
+ ...config.appId ? { appId: config.appId } : {},
926
+ ...config.callbackUri ? { callbackUri: config.callbackUri } : {}
927
+ },
928
+ ...createSlackDispatcherEventProcessorInput(config)
929
+ },
930
+ dependencies
931
+ );
932
+ }
107
933
  export {
934
+ buildSlackSuggestedActionButtonValue,
935
+ computeSlackSignature,
936
+ createSlackEventProcessor,
937
+ createSlackEventsApp,
938
+ createSlackFinalResultBlocks,
939
+ createSlackPostMessagePayload,
940
+ createSlackReactionPayload,
941
+ createSlackUpdateMessagePayload,
108
942
  encodeSlackThreadKey,
943
+ markdownToSlackMrkdwn,
109
944
  normalizeSlackAppMention,
945
+ parseSlackSuggestedActionButtonValue,
110
946
  parseSlackThreadKey,
111
- stripSlackAppMention
947
+ renderSlackAcknowledgement,
948
+ renderSlackFinalResult,
949
+ slackSourceReceiptReactionName,
950
+ startSlackIngress,
951
+ startSlackSocketModeApp,
952
+ startSlackSocketModeIngress,
953
+ stripSlackAppMention,
954
+ verifySlackSignature,
955
+ verifySlackTimestamp
112
956
  };
113
957
  //# sourceMappingURL=index.js.map