digital-workers 2.1.3 → 2.4.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.
Files changed (183) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +17 -0
  3. package/README.md +2 -0
  4. package/dist/actions.d.ts.map +1 -1
  5. package/dist/actions.js +33 -21
  6. package/dist/actions.js.map +1 -1
  7. package/dist/agent-comms.d.ts.map +1 -1
  8. package/dist/agent-comms.js +36 -25
  9. package/dist/agent-comms.js.map +1 -1
  10. package/dist/approve.d.ts +40 -8
  11. package/dist/approve.d.ts.map +1 -1
  12. package/dist/approve.js +86 -20
  13. package/dist/approve.js.map +1 -1
  14. package/dist/ask.d.ts +38 -7
  15. package/dist/ask.d.ts.map +1 -1
  16. package/dist/ask.js +85 -25
  17. package/dist/ask.js.map +1 -1
  18. package/dist/browse.d.ts +223 -0
  19. package/dist/browse.d.ts.map +1 -0
  20. package/dist/browse.js +392 -0
  21. package/dist/browse.js.map +1 -0
  22. package/dist/capability-tiers.js +3 -3
  23. package/dist/capability-tiers.js.map +1 -1
  24. package/dist/cascade-context.d.ts +28 -28
  25. package/dist/client.d.ts +162 -0
  26. package/dist/client.d.ts.map +1 -0
  27. package/dist/client.js +64 -0
  28. package/dist/client.js.map +1 -0
  29. package/dist/decide.d.ts +42 -6
  30. package/dist/decide.d.ts.map +1 -1
  31. package/dist/decide.js +54 -11
  32. package/dist/decide.js.map +1 -1
  33. package/dist/do.d.ts +36 -7
  34. package/dist/do.d.ts.map +1 -1
  35. package/dist/do.js +82 -39
  36. package/dist/do.js.map +1 -1
  37. package/dist/error-escalation.d.ts.map +1 -1
  38. package/dist/error-escalation.js +38 -38
  39. package/dist/error-escalation.js.map +1 -1
  40. package/dist/generate.d.ts +48 -7
  41. package/dist/generate.d.ts.map +1 -1
  42. package/dist/generate.js +49 -8
  43. package/dist/generate.js.map +1 -1
  44. package/dist/goals.d.ts +10 -9
  45. package/dist/goals.d.ts.map +1 -1
  46. package/dist/goals.js +30 -24
  47. package/dist/goals.js.map +1 -1
  48. package/dist/image.d.ts +189 -0
  49. package/dist/image.d.ts.map +1 -0
  50. package/dist/image.js +528 -0
  51. package/dist/image.js.map +1 -0
  52. package/dist/index.d.ts +49 -2
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/index.js +58 -2
  55. package/dist/index.js.map +1 -1
  56. package/dist/is.d.ts +45 -10
  57. package/dist/is.d.ts.map +1 -1
  58. package/dist/is.js +56 -21
  59. package/dist/is.js.map +1 -1
  60. package/dist/kpis.d.ts +24 -15
  61. package/dist/kpis.d.ts.map +1 -1
  62. package/dist/kpis.js +16 -14
  63. package/dist/kpis.js.map +1 -1
  64. package/dist/load-balancing.d.ts.map +1 -1
  65. package/dist/load-balancing.js +124 -38
  66. package/dist/load-balancing.js.map +1 -1
  67. package/dist/logger.d.ts +76 -0
  68. package/dist/logger.d.ts.map +1 -0
  69. package/dist/logger.js +39 -0
  70. package/dist/logger.js.map +1 -0
  71. package/dist/notify.d.ts +38 -9
  72. package/dist/notify.d.ts.map +1 -1
  73. package/dist/notify.js +72 -17
  74. package/dist/notify.js.map +1 -1
  75. package/dist/role.d.ts +5 -4
  76. package/dist/role.d.ts.map +1 -1
  77. package/dist/role.js +13 -10
  78. package/dist/role.js.map +1 -1
  79. package/dist/runtime.d.ts +310 -0
  80. package/dist/runtime.d.ts.map +1 -0
  81. package/dist/runtime.js +510 -0
  82. package/dist/runtime.js.map +1 -0
  83. package/dist/team.d.ts +11 -6
  84. package/dist/team.d.ts.map +1 -1
  85. package/dist/team.js +22 -15
  86. package/dist/team.js.map +1 -1
  87. package/dist/transports/email.d.ts +318 -0
  88. package/dist/transports/email.d.ts.map +1 -0
  89. package/dist/transports/email.js +779 -0
  90. package/dist/transports/email.js.map +1 -0
  91. package/dist/transports/slack.d.ts +515 -0
  92. package/dist/transports/slack.d.ts.map +1 -0
  93. package/dist/transports/slack.js +844 -0
  94. package/dist/transports/slack.js.map +1 -0
  95. package/dist/transports.d.ts.map +1 -1
  96. package/dist/transports.js +44 -25
  97. package/dist/transports.js.map +1 -1
  98. package/dist/types.d.ts +141 -19
  99. package/dist/types.d.ts.map +1 -1
  100. package/dist/types.js +5 -0
  101. package/dist/types.js.map +1 -1
  102. package/dist/utils/id.d.ts +19 -0
  103. package/dist/utils/id.d.ts.map +1 -0
  104. package/dist/utils/id.js +21 -0
  105. package/dist/utils/id.js.map +1 -0
  106. package/dist/video.d.ts +203 -0
  107. package/dist/video.d.ts.map +1 -0
  108. package/dist/video.js +528 -0
  109. package/dist/video.js.map +1 -0
  110. package/dist/worker.d.ts +343 -0
  111. package/dist/worker.d.ts.map +1 -0
  112. package/dist/worker.js +698 -0
  113. package/dist/worker.js.map +1 -0
  114. package/package.json +32 -14
  115. package/src/actions.ts +39 -30
  116. package/src/agent-comms.ts +54 -92
  117. package/src/approve.ts +91 -20
  118. package/src/ask.ts +99 -25
  119. package/src/browse.ts +627 -0
  120. package/src/capability-tiers.ts +5 -5
  121. package/src/client.ts +221 -0
  122. package/src/decide.ts +81 -35
  123. package/src/do.ts +98 -52
  124. package/src/error-escalation.ts +55 -67
  125. package/src/generate.ts +52 -18
  126. package/src/goals.ts +36 -27
  127. package/src/image.ts +816 -0
  128. package/src/index.ts +187 -2
  129. package/src/is.ts +59 -25
  130. package/src/kpis.ts +41 -36
  131. package/src/load-balancing.ts +132 -46
  132. package/src/logger.ts +93 -0
  133. package/src/notify.ts +78 -17
  134. package/src/role.ts +30 -20
  135. package/src/runtime.ts +796 -0
  136. package/src/team.ts +24 -19
  137. package/src/transports/email.ts +1160 -0
  138. package/src/transports/slack.ts +1320 -0
  139. package/src/transports.ts +58 -43
  140. package/src/types.ts +174 -46
  141. package/src/utils/id.ts +21 -0
  142. package/src/video.ts +906 -0
  143. package/src/worker.ts +1007 -0
  144. package/test/approve.test.ts +305 -0
  145. package/test/ask.test.ts +274 -0
  146. package/test/browse.test.ts +361 -0
  147. package/test/decide.test.ts +252 -0
  148. package/test/do.test.ts +144 -0
  149. package/test/error-logging.test.ts +357 -0
  150. package/test/generate.test.ts +319 -0
  151. package/test/image.test.ts +398 -0
  152. package/test/is.test.ts +287 -0
  153. package/test/load-balancing-safety.test.ts +404 -0
  154. package/test/notify.test.ts +434 -0
  155. package/test/primitives.test.ts +320 -0
  156. package/test/runtime-integration.test.ts +892 -0
  157. package/test/transports/crypto.test.ts +230 -0
  158. package/test/transports/email.test.ts +866 -0
  159. package/test/transports/id-generation.test.ts +91 -0
  160. package/test/transports/slack.test.ts +760 -0
  161. package/test/type-safety.test.ts +834 -0
  162. package/test/types.test.ts +60 -2
  163. package/test/video.test.ts +530 -0
  164. package/test/worker.test.ts +1433 -0
  165. package/tsconfig.json +4 -1
  166. package/vitest.config.ts +42 -0
  167. package/wrangler.jsonc +36 -0
  168. package/LICENSE +0 -21
  169. package/src/actions.js +0 -436
  170. package/src/approve.js +0 -234
  171. package/src/ask.js +0 -226
  172. package/src/decide.js +0 -244
  173. package/src/do.js +0 -227
  174. package/src/generate.js +0 -298
  175. package/src/goals.js +0 -205
  176. package/src/index.js +0 -68
  177. package/src/is.js +0 -317
  178. package/src/kpis.js +0 -270
  179. package/src/notify.js +0 -219
  180. package/src/role.js +0 -110
  181. package/src/team.js +0 -130
  182. package/src/transports.js +0 -357
  183. package/src/types.js +0 -71
@@ -0,0 +1,1320 @@
1
+ /**
2
+ * Slack Transport Adapter for Digital Workers
3
+ *
4
+ * Provides Slack-based communication for worker notifications, questions,
5
+ * and approval workflows using the Slack Web API and Block Kit.
6
+ *
7
+ * Features:
8
+ * - Send notifications to channels (#channel) and DMs (@user)
9
+ * - Rich message formatting with Block Kit
10
+ * - Interactive button components for approvals
11
+ * - Webhook handling for button interactions
12
+ * - Request signature verification
13
+ *
14
+ * @packageDocumentation
15
+ */
16
+
17
+ import type {
18
+ Transport,
19
+ TransportConfig,
20
+ MessagePayload,
21
+ MessageAction,
22
+ DeliveryResult,
23
+ TransportHandler,
24
+ } from '../transports.js'
25
+ import { registerTransport } from '../transports.js'
26
+ import type { Logger } from '../logger.js'
27
+ import { noopLogger } from '../logger.js'
28
+ import { generateRequestId } from '../utils/id.js'
29
+
30
+ // =============================================================================
31
+ // Crypto Functions for Signature Verification
32
+ // =============================================================================
33
+
34
+ /**
35
+ * Compute HMAC-SHA256 and return the result as a hex string.
36
+ * Uses the Web Crypto API which works in both Node.js and Cloudflare Workers.
37
+ *
38
+ * @param data - The data to sign
39
+ * @param secret - The signing secret
40
+ * @returns A hex-encoded HMAC-SHA256 hash
41
+ */
42
+ export async function computeHmacSha256Hex(data: string, secret: string): Promise<string> {
43
+ const encoder = new TextEncoder()
44
+
45
+ // Import the secret as a crypto key
46
+ const key = await crypto.subtle.importKey(
47
+ 'raw',
48
+ encoder.encode(secret),
49
+ { name: 'HMAC', hash: 'SHA-256' },
50
+ false,
51
+ ['sign']
52
+ )
53
+
54
+ // Sign the data
55
+ const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data))
56
+
57
+ // Convert to hex string
58
+ return Array.from(new Uint8Array(signature))
59
+ .map((b) => b.toString(16).padStart(2, '0'))
60
+ .join('')
61
+ }
62
+
63
+ /**
64
+ * Verify a Slack request signature using HMAC-SHA256.
65
+ * Uses the Web Crypto API which works in both Node.js and Cloudflare Workers.
66
+ *
67
+ * @param signature - The x-slack-signature header value (v0=...)
68
+ * @param timestamp - The x-slack-request-timestamp header value
69
+ * @param body - The raw request body
70
+ * @param signingSecret - The Slack signing secret
71
+ * @returns true if the signature is valid, false otherwise
72
+ */
73
+ export async function verifySlackSignature(
74
+ signature: string,
75
+ timestamp: string,
76
+ body: string,
77
+ signingSecret: string
78
+ ): Promise<boolean> {
79
+ // Slack signatures have the format "v0=<hex>"
80
+ if (!signature.startsWith('v0=')) {
81
+ return false
82
+ }
83
+
84
+ // Compute the expected signature
85
+ const baseString = `v0:${timestamp}:${body}`
86
+ const expectedHmac = await computeHmacSha256Hex(baseString, signingSecret)
87
+ const expectedSignature = `v0=${expectedHmac}`
88
+
89
+ // Constant-time comparison to prevent timing attacks
90
+ return secureCompare(signature, expectedSignature)
91
+ }
92
+
93
+ /**
94
+ * Constant-time string comparison to prevent timing attacks.
95
+ * Returns true if both strings are equal, false otherwise.
96
+ */
97
+ function secureCompare(a: string, b: string): boolean {
98
+ if (a.length !== b.length) {
99
+ return false
100
+ }
101
+
102
+ let result = 0
103
+ for (let i = 0; i < a.length; i++) {
104
+ result |= a.charCodeAt(i) ^ b.charCodeAt(i)
105
+ }
106
+ return result === 0
107
+ }
108
+
109
+ // =============================================================================
110
+ // Slack API Types
111
+ // =============================================================================
112
+
113
+ /**
114
+ * Slack transport configuration
115
+ */
116
+ export interface SlackTransportConfig extends TransportConfig {
117
+ transport: 'slack'
118
+ /** Bot OAuth token (xoxb-...) */
119
+ botToken: string
120
+ /** Signing secret for webhook verification */
121
+ signingSecret: string
122
+ /** Optional app ID */
123
+ appId?: string
124
+ /** Optional default channel for notifications */
125
+ defaultChannel?: string
126
+ /** API base URL (for testing/enterprise) */
127
+ apiUrl?: string
128
+ /** Optional logger for error logging */
129
+ logger?: Logger
130
+ }
131
+
132
+ /**
133
+ * Slack Block Kit block types
134
+ */
135
+ export type SlackBlockType =
136
+ | 'section'
137
+ | 'divider'
138
+ | 'header'
139
+ | 'context'
140
+ | 'actions'
141
+ | 'image'
142
+ | 'input'
143
+
144
+ /**
145
+ * Slack text object
146
+ */
147
+ export interface SlackTextObject {
148
+ type: 'plain_text' | 'mrkdwn'
149
+ text: string
150
+ emoji?: boolean
151
+ verbatim?: boolean
152
+ }
153
+
154
+ /**
155
+ * Slack button element
156
+ */
157
+ export interface SlackButtonElement {
158
+ type: 'button'
159
+ text: SlackTextObject
160
+ action_id: string
161
+ value?: string
162
+ style?: 'primary' | 'danger'
163
+ url?: string
164
+ confirm?: SlackConfirmDialog
165
+ }
166
+
167
+ /**
168
+ * Slack confirm dialog
169
+ */
170
+ export interface SlackConfirmDialog {
171
+ title: SlackTextObject
172
+ text: SlackTextObject
173
+ confirm: SlackTextObject
174
+ deny: SlackTextObject
175
+ style?: 'primary' | 'danger'
176
+ }
177
+
178
+ /**
179
+ * Slack section block
180
+ */
181
+ export interface SlackSectionBlock {
182
+ type: 'section'
183
+ text?: SlackTextObject
184
+ block_id?: string
185
+ fields?: SlackTextObject[]
186
+ accessory?: SlackButtonElement
187
+ }
188
+
189
+ /**
190
+ * Slack divider block
191
+ */
192
+ export interface SlackDividerBlock {
193
+ type: 'divider'
194
+ block_id?: string
195
+ }
196
+
197
+ /**
198
+ * Slack header block
199
+ */
200
+ export interface SlackHeaderBlock {
201
+ type: 'header'
202
+ text: SlackTextObject
203
+ block_id?: string
204
+ }
205
+
206
+ /**
207
+ * Slack context block
208
+ */
209
+ export interface SlackContextBlock {
210
+ type: 'context'
211
+ elements: SlackTextObject[]
212
+ block_id?: string
213
+ }
214
+
215
+ /**
216
+ * Slack actions block
217
+ */
218
+ export interface SlackActionsBlock {
219
+ type: 'actions'
220
+ elements: SlackButtonElement[]
221
+ block_id?: string
222
+ }
223
+
224
+ /**
225
+ * Union of all Slack block types
226
+ */
227
+ export type SlackBlock =
228
+ | SlackSectionBlock
229
+ | SlackDividerBlock
230
+ | SlackHeaderBlock
231
+ | SlackContextBlock
232
+ | SlackActionsBlock
233
+
234
+ /**
235
+ * Slack message payload
236
+ */
237
+ export interface SlackMessage {
238
+ channel: string
239
+ text: string
240
+ blocks?: SlackBlock[]
241
+ thread_ts?: string
242
+ reply_broadcast?: boolean
243
+ unfurl_links?: boolean
244
+ unfurl_media?: boolean
245
+ metadata?: {
246
+ event_type: string
247
+ event_payload: Record<string, unknown>
248
+ }
249
+ }
250
+
251
+ /**
252
+ * Slack API response
253
+ */
254
+ export interface SlackApiResponse<T = unknown> {
255
+ ok: boolean
256
+ error?: string
257
+ warning?: string
258
+ response_metadata?: {
259
+ scopes?: string[]
260
+ acceptedScopes?: string[]
261
+ warnings?: string[]
262
+ }
263
+ ts?: string
264
+ channel?:
265
+ | string
266
+ | {
267
+ id: string
268
+ name?: string
269
+ is_channel?: boolean
270
+ is_group?: boolean
271
+ is_im?: boolean
272
+ is_mpim?: boolean
273
+ is_private?: boolean
274
+ is_member?: boolean
275
+ }
276
+ message?: T
277
+ }
278
+
279
+ /**
280
+ * Slack post message response
281
+ */
282
+ export interface SlackPostMessageResponse extends SlackApiResponse {
283
+ ts: string
284
+ channel: string
285
+ message: {
286
+ type: string
287
+ subtype?: string
288
+ text: string
289
+ ts: string
290
+ username?: string
291
+ bot_id?: string
292
+ blocks?: SlackBlock[]
293
+ }
294
+ }
295
+
296
+ /**
297
+ * Slack user info response
298
+ */
299
+ export interface SlackUserInfoResponse extends SlackApiResponse {
300
+ user: {
301
+ id: string
302
+ team_id: string
303
+ name: string
304
+ real_name: string
305
+ profile: {
306
+ email?: string
307
+ display_name?: string
308
+ }
309
+ is_bot: boolean
310
+ }
311
+ }
312
+
313
+ /**
314
+ * Slack conversation info response
315
+ */
316
+ export interface SlackConversationInfoResponse extends SlackApiResponse {
317
+ channel: {
318
+ id: string
319
+ name: string
320
+ is_channel: boolean
321
+ is_group: boolean
322
+ is_im: boolean
323
+ is_mpim: boolean
324
+ is_private: boolean
325
+ is_member: boolean
326
+ }
327
+ }
328
+
329
+ /**
330
+ * Slack interaction payload (from button clicks, etc.)
331
+ */
332
+ export interface SlackInteractionPayload {
333
+ type: 'block_actions' | 'view_submission' | 'view_closed' | 'shortcut'
334
+ team: {
335
+ id: string
336
+ domain: string
337
+ }
338
+ user: {
339
+ id: string
340
+ username: string
341
+ name: string
342
+ team_id: string
343
+ }
344
+ channel?: {
345
+ id: string
346
+ name: string
347
+ }
348
+ message?: {
349
+ type: string
350
+ ts: string
351
+ text: string
352
+ blocks?: SlackBlock[]
353
+ }
354
+ container?: {
355
+ type: string
356
+ message_ts: string
357
+ channel_id: string
358
+ }
359
+ actions?: SlackActionPayload[]
360
+ response_url: string
361
+ trigger_id: string
362
+ api_app_id: string
363
+ token: string // Deprecated but still sent
364
+ }
365
+
366
+ /**
367
+ * Slack action payload (button click data)
368
+ */
369
+ export interface SlackActionPayload {
370
+ type: 'button'
371
+ action_id: string
372
+ block_id: string
373
+ value: string
374
+ action_ts: string
375
+ }
376
+
377
+ /**
378
+ * Webhook request for signature verification
379
+ */
380
+ export interface SlackWebhookRequest {
381
+ headers: {
382
+ 'x-slack-signature': string
383
+ 'x-slack-request-timestamp': string
384
+ [key: string]: string
385
+ }
386
+ body: string | SlackInteractionPayload
387
+ rawBody?: string
388
+ }
389
+
390
+ /**
391
+ * Webhook handler result
392
+ */
393
+ export interface WebhookHandlerResult {
394
+ success: boolean
395
+ actionId?: string
396
+ userId?: string
397
+ channelId?: string
398
+ messageTs?: string
399
+ value?: unknown
400
+ error?: string
401
+ }
402
+
403
+ // =============================================================================
404
+ // SlackTransport Class
405
+ // =============================================================================
406
+
407
+ /**
408
+ * Slack Transport for digital-workers communication
409
+ *
410
+ * @example
411
+ * ```ts
412
+ * const slack = new SlackTransport({
413
+ * botToken: process.env.SLACK_BOT_TOKEN!,
414
+ * signingSecret: process.env.SLACK_SIGNING_SECRET!,
415
+ * })
416
+ *
417
+ * // Send notification to a channel
418
+ * await slack.sendNotification('#engineering', 'Deployment complete!')
419
+ *
420
+ * // Send approval request
421
+ * const result = await slack.sendApprovalRequest('@alice', 'Approve deployment?', {
422
+ * context: { version: '2.1.0' },
423
+ * })
424
+ *
425
+ * // Handle webhook
426
+ * app.post('/slack/events', async (req, res) => {
427
+ * const result = await slack.handleWebhook(req)
428
+ * res.json({ ok: result.success })
429
+ * })
430
+ * ```
431
+ */
432
+ export class SlackTransport {
433
+ private config: SlackTransportConfig
434
+ private apiBaseUrl: string
435
+ private logger: Logger
436
+
437
+ constructor(config: Omit<SlackTransportConfig, 'transport'>) {
438
+ this.config = {
439
+ ...config,
440
+ transport: 'slack',
441
+ }
442
+ this.apiBaseUrl = config.apiUrl || 'https://slack.com/api'
443
+ this.logger = config.logger ?? noopLogger
444
+ }
445
+
446
+ // ===========================================================================
447
+ // Public Methods
448
+ // ===========================================================================
449
+
450
+ /**
451
+ * Send a notification message
452
+ *
453
+ * @param target - Channel (#channel) or user (@user or user ID)
454
+ * @param message - Message text
455
+ * @param options - Additional message options
456
+ */
457
+ async sendNotification(
458
+ target: string,
459
+ message: string,
460
+ options: {
461
+ threadTs?: string
462
+ priority?: 'low' | 'normal' | 'high' | 'urgent'
463
+ metadata?: Record<string, unknown>
464
+ } = {}
465
+ ): Promise<DeliveryResult> {
466
+ try {
467
+ const channel = await this.resolveTarget(target)
468
+ const blocks = this.formatNotificationBlocks(message, options)
469
+
470
+ const thread_ts = options.threadTs
471
+ const metadata = options.metadata
472
+ ? {
473
+ event_type: 'notification',
474
+ event_payload: options.metadata,
475
+ }
476
+ : undefined
477
+
478
+ const response = await this.postMessage({
479
+ channel,
480
+ text: message,
481
+ blocks,
482
+ ...(thread_ts !== undefined && { thread_ts }),
483
+ ...(metadata !== undefined && { metadata }),
484
+ })
485
+
486
+ return {
487
+ success: response.ok,
488
+ transport: 'slack',
489
+ messageId: response.ts,
490
+ metadata: {
491
+ channel: response.channel,
492
+ ts: response.ts,
493
+ },
494
+ }
495
+ } catch (error) {
496
+ return {
497
+ success: false,
498
+ transport: 'slack',
499
+ error: error instanceof Error ? error.message : String(error),
500
+ }
501
+ }
502
+ }
503
+
504
+ /**
505
+ * Send an approval request with interactive buttons
506
+ *
507
+ * @param target - Channel (#channel) or user (@user or user ID)
508
+ * @param request - Approval request text
509
+ * @param options - Additional options
510
+ */
511
+ async sendApprovalRequest(
512
+ target: string,
513
+ request: string,
514
+ options: {
515
+ context?: Record<string, unknown>
516
+ approveLabel?: string
517
+ rejectLabel?: string
518
+ requestId?: string
519
+ timeout?: number
520
+ } = {}
521
+ ): Promise<DeliveryResult> {
522
+ try {
523
+ const channel = await this.resolveTarget(target)
524
+ const requestId = options.requestId || this.generateRequestId()
525
+ const blocks = this.formatApprovalBlocks(request, {
526
+ ...options,
527
+ requestId,
528
+ })
529
+
530
+ const response = await this.postMessage({
531
+ channel,
532
+ text: `Approval Request: ${request}`,
533
+ blocks,
534
+ metadata: {
535
+ event_type: 'approval_request',
536
+ event_payload: {
537
+ requestId,
538
+ context: options.context,
539
+ timeout: options.timeout,
540
+ },
541
+ },
542
+ })
543
+
544
+ return {
545
+ success: response.ok,
546
+ transport: 'slack',
547
+ messageId: response.ts,
548
+ metadata: {
549
+ channel: response.channel,
550
+ ts: response.ts,
551
+ requestId,
552
+ },
553
+ }
554
+ } catch (error) {
555
+ return {
556
+ success: false,
557
+ transport: 'slack',
558
+ error: error instanceof Error ? error.message : String(error),
559
+ }
560
+ }
561
+ }
562
+
563
+ /**
564
+ * Send a question with optional response options
565
+ *
566
+ * @param target - Channel (#channel) or user (@user or user ID)
567
+ * @param question - Question text
568
+ * @param options - Additional options
569
+ */
570
+ async sendQuestion(
571
+ target: string,
572
+ question: string,
573
+ options: {
574
+ choices?: string[]
575
+ threadTs?: string
576
+ requestId?: string
577
+ } = {}
578
+ ): Promise<DeliveryResult> {
579
+ try {
580
+ const channel = await this.resolveTarget(target)
581
+ const requestId = options.requestId || this.generateRequestId()
582
+ const blocks = this.formatQuestionBlocks(question, {
583
+ ...options,
584
+ requestId,
585
+ })
586
+
587
+ const thread_ts = options.threadTs
588
+ const choices = options.choices
589
+ const metadata = {
590
+ event_type: 'question',
591
+ event_payload: {
592
+ requestId,
593
+ ...(choices !== undefined && { choices }),
594
+ },
595
+ }
596
+
597
+ const response = await this.postMessage({
598
+ channel,
599
+ text: question,
600
+ blocks,
601
+ ...(thread_ts !== undefined && { thread_ts }),
602
+ ...(metadata !== undefined && { metadata }),
603
+ })
604
+
605
+ return {
606
+ success: response.ok,
607
+ transport: 'slack',
608
+ messageId: response.ts,
609
+ metadata: {
610
+ channel: response.channel,
611
+ ts: response.ts,
612
+ requestId,
613
+ },
614
+ }
615
+ } catch (error) {
616
+ return {
617
+ success: false,
618
+ transport: 'slack',
619
+ error: error instanceof Error ? error.message : String(error),
620
+ }
621
+ }
622
+ }
623
+
624
+ /**
625
+ * Handle incoming webhook from Slack (button interactions, etc.)
626
+ *
627
+ * @param request - Webhook request with headers and body
628
+ */
629
+ async handleWebhook(request: SlackWebhookRequest): Promise<WebhookHandlerResult> {
630
+ // Verify signature using async Web Crypto API
631
+ try {
632
+ const isValid = await this.verifySignatureAsync(request)
633
+ if (!isValid) {
634
+ return {
635
+ success: false,
636
+ error: 'Invalid request signature',
637
+ }
638
+ }
639
+ } catch (error) {
640
+ return {
641
+ success: false,
642
+ error: error instanceof Error ? error.message : 'Signature verification failed',
643
+ }
644
+ }
645
+
646
+ // Parse payload
647
+ const payload = this.parseWebhookPayload(request)
648
+ if (!payload) {
649
+ return {
650
+ success: false,
651
+ error: 'Invalid webhook payload',
652
+ }
653
+ }
654
+
655
+ // Handle block actions (button clicks)
656
+ if (payload.type === 'block_actions' && payload.actions?.length) {
657
+ const action = payload.actions[0]
658
+ if (!action) {
659
+ return {
660
+ success: false,
661
+ error: 'No action found in payload',
662
+ }
663
+ }
664
+
665
+ const channelId = payload.channel?.id
666
+ const messageTs = payload.message?.ts
667
+
668
+ return {
669
+ success: true,
670
+ actionId: action.action_id,
671
+ userId: payload.user.id,
672
+ ...(channelId !== undefined && { channelId }),
673
+ ...(messageTs !== undefined && { messageTs }),
674
+ value: this.parseActionValue(action.value),
675
+ }
676
+ }
677
+
678
+ return {
679
+ success: false,
680
+ error: `Unsupported interaction type: ${payload.type}`,
681
+ }
682
+ }
683
+
684
+ /**
685
+ * Update an existing message (for approval status updates, etc.)
686
+ *
687
+ * @param channel - Channel ID
688
+ * @param ts - Message timestamp
689
+ * @param text - New text
690
+ * @param blocks - New blocks
691
+ */
692
+ async updateMessage(
693
+ channel: string,
694
+ ts: string,
695
+ text: string,
696
+ blocks?: SlackBlock[]
697
+ ): Promise<SlackApiResponse> {
698
+ return this.callApi('chat.update', {
699
+ channel,
700
+ ts,
701
+ text,
702
+ blocks,
703
+ })
704
+ }
705
+
706
+ /**
707
+ * Open a DM channel with a user
708
+ *
709
+ * @param userId - User ID to open DM with
710
+ */
711
+ async openDM(userId: string): Promise<string> {
712
+ const response = await this.callApi<SlackApiResponse<unknown> & { channel?: { id: string } }>(
713
+ 'conversations.open',
714
+ {
715
+ users: userId,
716
+ }
717
+ )
718
+
719
+ if (!response.ok || !response.channel?.id) {
720
+ throw new Error(response.error || 'Failed to open DM')
721
+ }
722
+
723
+ return response.channel.id
724
+ }
725
+
726
+ /**
727
+ * Look up user by email
728
+ *
729
+ * @param email - User email address
730
+ */
731
+ async lookupUserByEmail(email: string): Promise<string | null> {
732
+ try {
733
+ const response = await this.callApi<SlackUserInfoResponse>('users.lookupByEmail', {
734
+ email,
735
+ })
736
+
737
+ if (!response.ok) {
738
+ return null
739
+ }
740
+
741
+ return response.user?.id || null
742
+ } catch (error) {
743
+ // User not found or other API error - log for debugging
744
+ this.logger.error('lookupUserByEmail failed', error instanceof Error ? error : undefined, {
745
+ email,
746
+ operation: 'lookupUserByEmail',
747
+ })
748
+ return null
749
+ }
750
+ }
751
+
752
+ /**
753
+ * Get the transport handler for registration
754
+ */
755
+ getHandler(): TransportHandler {
756
+ return async (payload: MessagePayload, config: TransportConfig): Promise<DeliveryResult> => {
757
+ const target = Array.isArray(payload.to) ? payload.to[0] : payload.to
758
+ if (!target) {
759
+ return {
760
+ success: false,
761
+ transport: 'slack',
762
+ error: 'No target specified',
763
+ }
764
+ }
765
+
766
+ if (payload.type === 'approval') {
767
+ const context = payload.metadata
768
+ return this.sendApprovalRequest(target, payload.body, {
769
+ ...(context !== undefined && { context }),
770
+ })
771
+ }
772
+
773
+ if (payload.type === 'question') {
774
+ const choices = payload.actions?.map((a) => a.label)
775
+ return this.sendQuestion(target, payload.body, {
776
+ ...(choices !== undefined && { choices }),
777
+ })
778
+ }
779
+
780
+ const priority = payload.priority
781
+ const metadata = payload.metadata
782
+ return this.sendNotification(target, payload.body, {
783
+ ...(priority !== undefined && { priority }),
784
+ ...(metadata !== undefined && { metadata }),
785
+ })
786
+ }
787
+ }
788
+
789
+ /**
790
+ * Register this transport with the transport registry
791
+ */
792
+ register(): void {
793
+ registerTransport('slack', this.getHandler())
794
+ }
795
+
796
+ // ===========================================================================
797
+ // Private Methods
798
+ // ===========================================================================
799
+
800
+ /**
801
+ * Resolve target to channel ID
802
+ * - #channel -> channel name lookup
803
+ * - @user -> DM with user
804
+ * - C/U/D ID -> direct use
805
+ */
806
+ private async resolveTarget(target: string): Promise<string> {
807
+ // Already a channel/user ID
808
+ if (/^[CUD][A-Z0-9]+$/.test(target)) {
809
+ return target
810
+ }
811
+
812
+ // Channel reference (#channel)
813
+ if (target.startsWith('#')) {
814
+ // Return channel name, Slack API accepts this
815
+ return target.slice(1)
816
+ }
817
+
818
+ // User reference (@user)
819
+ if (target.startsWith('@')) {
820
+ const username = target.slice(1)
821
+ // Try to find user and open DM
822
+ const userId = await this.findUserByName(username)
823
+ if (userId) {
824
+ return this.openDM(userId)
825
+ }
826
+ throw new Error(`User not found: ${username}`)
827
+ }
828
+
829
+ // Assume it's a channel name or ID
830
+ return target
831
+ }
832
+
833
+ /**
834
+ * Find user by display name (limited functionality)
835
+ */
836
+ private async findUserByName(name: string): Promise<string | null> {
837
+ // Note: This would require users:read scope and iterating through users
838
+ // For production, you'd want to implement proper user lookup
839
+ // or use users.lookupByEmail if you have the email
840
+ return null
841
+ }
842
+
843
+ /**
844
+ * Format notification message blocks
845
+ */
846
+ private formatNotificationBlocks(
847
+ message: string,
848
+ options: {
849
+ priority?: 'low' | 'normal' | 'high' | 'urgent'
850
+ metadata?: Record<string, unknown>
851
+ }
852
+ ): SlackBlock[] {
853
+ const blocks: SlackBlock[] = []
854
+
855
+ // Add priority indicator for high/urgent
856
+ if (options.priority === 'urgent' || options.priority === 'high') {
857
+ const emoji = options.priority === 'urgent' ? ':rotating_light:' : ':warning:'
858
+ blocks.push({
859
+ type: 'header',
860
+ text: {
861
+ type: 'plain_text',
862
+ text: `${emoji} ${options.priority.toUpperCase()}`,
863
+ emoji: true,
864
+ },
865
+ })
866
+ }
867
+
868
+ // Main message
869
+ blocks.push({
870
+ type: 'section',
871
+ text: {
872
+ type: 'mrkdwn',
873
+ text: message,
874
+ },
875
+ })
876
+
877
+ // Add context if metadata provided
878
+ if (options.metadata && Object.keys(options.metadata).length > 0) {
879
+ blocks.push({
880
+ type: 'divider',
881
+ })
882
+ blocks.push({
883
+ type: 'context',
884
+ elements: [
885
+ {
886
+ type: 'mrkdwn',
887
+ text: Object.entries(options.metadata)
888
+ .map(([k, v]) => `*${k}:* ${v}`)
889
+ .join(' | '),
890
+ },
891
+ ],
892
+ })
893
+ }
894
+
895
+ return blocks
896
+ }
897
+
898
+ /**
899
+ * Format approval request blocks with buttons
900
+ */
901
+ private formatApprovalBlocks(
902
+ request: string,
903
+ options: {
904
+ context?: Record<string, unknown>
905
+ approveLabel?: string
906
+ rejectLabel?: string
907
+ requestId: string
908
+ }
909
+ ): SlackBlock[] {
910
+ const blocks: SlackBlock[] = []
911
+
912
+ // Header
913
+ blocks.push({
914
+ type: 'header',
915
+ text: {
916
+ type: 'plain_text',
917
+ text: 'Approval Request',
918
+ emoji: true,
919
+ },
920
+ })
921
+
922
+ // Request text
923
+ blocks.push({
924
+ type: 'section',
925
+ text: {
926
+ type: 'mrkdwn',
927
+ text: request,
928
+ },
929
+ })
930
+
931
+ // Context information
932
+ if (options.context && Object.keys(options.context).length > 0) {
933
+ blocks.push({
934
+ type: 'divider',
935
+ })
936
+
937
+ const contextFields: SlackTextObject[] = Object.entries(options.context).map(([k, v]) => ({
938
+ type: 'mrkdwn' as const,
939
+ text: `*${k}:*\n${v}`,
940
+ }))
941
+
942
+ // Split into chunks of 10 (Slack's limit for fields)
943
+ for (let i = 0; i < contextFields.length; i += 10) {
944
+ blocks.push({
945
+ type: 'section',
946
+ fields: contextFields.slice(i, i + 10),
947
+ })
948
+ }
949
+ }
950
+
951
+ // Action buttons
952
+ blocks.push({
953
+ type: 'divider',
954
+ })
955
+ blocks.push({
956
+ type: 'actions',
957
+ block_id: `approval_actions_${options.requestId}`,
958
+ elements: [
959
+ {
960
+ type: 'button',
961
+ text: {
962
+ type: 'plain_text',
963
+ text: options.approveLabel || 'Approve',
964
+ emoji: true,
965
+ },
966
+ style: 'primary',
967
+ action_id: `approve_${options.requestId}`,
968
+ value: JSON.stringify({ action: 'approve', requestId: options.requestId }),
969
+ confirm: {
970
+ title: { type: 'plain_text', text: 'Confirm Approval' },
971
+ text: { type: 'mrkdwn', text: 'Are you sure you want to approve this request?' },
972
+ confirm: { type: 'plain_text', text: 'Approve' },
973
+ deny: { type: 'plain_text', text: 'Cancel' },
974
+ },
975
+ },
976
+ {
977
+ type: 'button',
978
+ text: {
979
+ type: 'plain_text',
980
+ text: options.rejectLabel || 'Reject',
981
+ emoji: true,
982
+ },
983
+ style: 'danger',
984
+ action_id: `reject_${options.requestId}`,
985
+ value: JSON.stringify({ action: 'reject', requestId: options.requestId }),
986
+ confirm: {
987
+ title: { type: 'plain_text', text: 'Confirm Rejection' },
988
+ text: { type: 'mrkdwn', text: 'Are you sure you want to reject this request?' },
989
+ confirm: { type: 'plain_text', text: 'Reject' },
990
+ deny: { type: 'plain_text', text: 'Cancel' },
991
+ style: 'danger',
992
+ },
993
+ },
994
+ ],
995
+ })
996
+
997
+ return blocks
998
+ }
999
+
1000
+ /**
1001
+ * Format question blocks with optional choice buttons
1002
+ */
1003
+ private formatQuestionBlocks(
1004
+ question: string,
1005
+ options: {
1006
+ choices?: string[]
1007
+ requestId: string
1008
+ }
1009
+ ): SlackBlock[] {
1010
+ const blocks: SlackBlock[] = []
1011
+
1012
+ // Question text
1013
+ blocks.push({
1014
+ type: 'section',
1015
+ text: {
1016
+ type: 'mrkdwn',
1017
+ text: question,
1018
+ },
1019
+ })
1020
+
1021
+ // Choice buttons if provided
1022
+ if (options.choices && options.choices.length > 0) {
1023
+ blocks.push({
1024
+ type: 'actions',
1025
+ block_id: `question_choices_${options.requestId}`,
1026
+ elements: options.choices.slice(0, 5).map(
1027
+ (choice, index): SlackButtonElement => ({
1028
+ type: 'button',
1029
+ text: {
1030
+ type: 'plain_text',
1031
+ text: choice,
1032
+ emoji: true,
1033
+ },
1034
+ action_id: `choice_${options.requestId}_${index}`,
1035
+ value: JSON.stringify({ choice, requestId: options.requestId }),
1036
+ })
1037
+ ),
1038
+ })
1039
+ }
1040
+
1041
+ return blocks
1042
+ }
1043
+
1044
+ /**
1045
+ * Post a message to Slack
1046
+ */
1047
+ private async postMessage(message: SlackMessage): Promise<SlackPostMessageResponse> {
1048
+ return this.callApi<SlackPostMessageResponse>(
1049
+ 'chat.postMessage',
1050
+ message as unknown as Record<string, unknown>
1051
+ )
1052
+ }
1053
+
1054
+ /**
1055
+ * Call Slack API
1056
+ */
1057
+ private async callApi<T extends SlackApiResponse = SlackApiResponse>(
1058
+ method: string,
1059
+ body: Record<string, unknown>
1060
+ ): Promise<T> {
1061
+ const response = await fetch(`${this.apiBaseUrl}/${method}`, {
1062
+ method: 'POST',
1063
+ headers: {
1064
+ Authorization: `Bearer ${this.config.botToken}`,
1065
+ 'Content-Type': 'application/json; charset=utf-8',
1066
+ },
1067
+ body: JSON.stringify(body),
1068
+ })
1069
+
1070
+ if (!response.ok) {
1071
+ throw new Error(`Slack API error: ${response.status} ${response.statusText}`)
1072
+ }
1073
+
1074
+ const data = (await response.json()) as T
1075
+ if (!data.ok) {
1076
+ throw new Error(`Slack API error: ${data.error}`)
1077
+ }
1078
+
1079
+ return data
1080
+ }
1081
+
1082
+ /**
1083
+ * Verify Slack request signature using Web Crypto API.
1084
+ * Works in both Node.js and Cloudflare Workers environments.
1085
+ */
1086
+ private async verifySignatureAsync(request: SlackWebhookRequest): Promise<boolean> {
1087
+ const signature = request.headers['x-slack-signature']
1088
+ const timestamp = request.headers['x-slack-request-timestamp']
1089
+
1090
+ if (!signature || !timestamp) {
1091
+ return false
1092
+ }
1093
+
1094
+ // Check timestamp to prevent replay attacks (5 minutes)
1095
+ const now = Math.floor(Date.now() / 1000)
1096
+ const requestTimestamp = parseInt(timestamp, 10)
1097
+ if (Math.abs(now - requestTimestamp) > 300) {
1098
+ return false
1099
+ }
1100
+
1101
+ // Get raw body for verification
1102
+ const rawBody =
1103
+ request.rawBody ||
1104
+ (typeof request.body === 'string' ? request.body : JSON.stringify(request.body))
1105
+
1106
+ // Use the exported async signature verification function
1107
+ return verifySlackSignature(signature, timestamp, rawBody, this.config.signingSecret)
1108
+ }
1109
+
1110
+ /**
1111
+ * Parse webhook payload
1112
+ */
1113
+ private parseWebhookPayload(request: SlackWebhookRequest): SlackInteractionPayload | null {
1114
+ try {
1115
+ if (typeof request.body === 'string') {
1116
+ // URL-encoded payload (application/x-www-form-urlencoded)
1117
+ if (request.body.startsWith('payload=')) {
1118
+ const decoded = decodeURIComponent(request.body.slice(8))
1119
+ return JSON.parse(decoded) as SlackInteractionPayload
1120
+ }
1121
+ // JSON payload
1122
+ return JSON.parse(request.body) as SlackInteractionPayload
1123
+ }
1124
+ return request.body as SlackInteractionPayload
1125
+ } catch (error) {
1126
+ // Parse error - log for debugging
1127
+ this.logger.error('parseWebhookPayload failed', error instanceof Error ? error : undefined, {
1128
+ operation: 'parseWebhookPayload',
1129
+ bodyType: typeof request.body,
1130
+ bodyPreview: typeof request.body === 'string' ? request.body.slice(0, 100) : '[object]',
1131
+ })
1132
+ return null
1133
+ }
1134
+ }
1135
+
1136
+ /**
1137
+ * Parse action value (JSON or string)
1138
+ */
1139
+ private parseActionValue(value: string): unknown {
1140
+ try {
1141
+ return JSON.parse(value)
1142
+ } catch {
1143
+ // Non-JSON value - this is expected for string values, log at debug level
1144
+ this.logger.debug('parseActionValue: value is not JSON, returning as string', {
1145
+ operation: 'parseActionValue',
1146
+ valuePreview: value.slice(0, 50),
1147
+ })
1148
+ return value
1149
+ }
1150
+ }
1151
+
1152
+ /**
1153
+ * Generate unique request ID
1154
+ */
1155
+ private generateRequestId(): string {
1156
+ return generateRequestId('req')
1157
+ }
1158
+
1159
+ // ===========================================================================
1160
+ // Testing Utilities
1161
+ // ===========================================================================
1162
+
1163
+ /**
1164
+ * Expose parseWebhookPayload for testing
1165
+ * @internal
1166
+ */
1167
+ parseWebhookPayloadForTesting(request: SlackWebhookRequest): SlackInteractionPayload | null {
1168
+ return this.parseWebhookPayload(request)
1169
+ }
1170
+
1171
+ /**
1172
+ * Expose parseActionValue for testing
1173
+ * @internal
1174
+ */
1175
+ parseActionValueForTesting(value: string): unknown {
1176
+ return this.parseActionValue(value)
1177
+ }
1178
+ }
1179
+
1180
+ // =============================================================================
1181
+ // Factory Functions
1182
+ // =============================================================================
1183
+
1184
+ /**
1185
+ * Create a Slack transport instance
1186
+ *
1187
+ * @example
1188
+ * ```ts
1189
+ * const slack = createSlackTransport({
1190
+ * botToken: process.env.SLACK_BOT_TOKEN!,
1191
+ * signingSecret: process.env.SLACK_SIGNING_SECRET!,
1192
+ * })
1193
+ *
1194
+ * await slack.sendNotification('#engineering', 'Hello!')
1195
+ * ```
1196
+ */
1197
+ export function createSlackTransport(
1198
+ config: Omit<SlackTransportConfig, 'transport'>
1199
+ ): SlackTransport {
1200
+ return new SlackTransport(config)
1201
+ }
1202
+
1203
+ /**
1204
+ * Create and register a Slack transport handler
1205
+ *
1206
+ * @example
1207
+ * ```ts
1208
+ * registerSlackTransport({
1209
+ * botToken: process.env.SLACK_BOT_TOKEN!,
1210
+ * signingSecret: process.env.SLACK_SIGNING_SECRET!,
1211
+ * })
1212
+ *
1213
+ * // Now 'slack' transport is available via sendViaTransport
1214
+ * await sendViaTransport('slack', payload)
1215
+ * ```
1216
+ */
1217
+ export function registerSlackTransport(
1218
+ config: Omit<SlackTransportConfig, 'transport'>
1219
+ ): SlackTransport {
1220
+ const transport = createSlackTransport(config)
1221
+ transport.register()
1222
+ return transport
1223
+ }
1224
+
1225
+ // =============================================================================
1226
+ // Block Kit Helpers
1227
+ // =============================================================================
1228
+
1229
+ /**
1230
+ * Create a section block
1231
+ */
1232
+ export function slackSection(text: string, options?: { fields?: string[] }): SlackSectionBlock {
1233
+ const block: SlackSectionBlock = {
1234
+ type: 'section',
1235
+ text: {
1236
+ type: 'mrkdwn',
1237
+ text,
1238
+ },
1239
+ }
1240
+
1241
+ if (options?.fields) {
1242
+ block.fields = options.fields.map((f) => ({
1243
+ type: 'mrkdwn' as const,
1244
+ text: f,
1245
+ }))
1246
+ }
1247
+
1248
+ return block
1249
+ }
1250
+
1251
+ /**
1252
+ * Create a header block
1253
+ */
1254
+ export function slackHeader(text: string): SlackHeaderBlock {
1255
+ return {
1256
+ type: 'header',
1257
+ text: {
1258
+ type: 'plain_text',
1259
+ text,
1260
+ emoji: true,
1261
+ },
1262
+ }
1263
+ }
1264
+
1265
+ /**
1266
+ * Create a divider block
1267
+ */
1268
+ export function slackDivider(): SlackDividerBlock {
1269
+ return { type: 'divider' }
1270
+ }
1271
+
1272
+ /**
1273
+ * Create a context block
1274
+ */
1275
+ export function slackContext(...texts: string[]): SlackContextBlock {
1276
+ return {
1277
+ type: 'context',
1278
+ elements: texts.map((text) => ({
1279
+ type: 'mrkdwn' as const,
1280
+ text,
1281
+ })),
1282
+ }
1283
+ }
1284
+
1285
+ /**
1286
+ * Create a button element
1287
+ */
1288
+ export function slackButton(
1289
+ text: string,
1290
+ actionId: string,
1291
+ options?: {
1292
+ value?: string
1293
+ style?: 'primary' | 'danger'
1294
+ url?: string
1295
+ }
1296
+ ): SlackButtonElement {
1297
+ return {
1298
+ type: 'button',
1299
+ text: {
1300
+ type: 'plain_text',
1301
+ text,
1302
+ emoji: true,
1303
+ },
1304
+ action_id: actionId,
1305
+ ...(options?.value !== undefined && { value: options.value }),
1306
+ ...(options?.style !== undefined && { style: options.style }),
1307
+ ...(options?.url !== undefined && { url: options.url }),
1308
+ }
1309
+ }
1310
+
1311
+ /**
1312
+ * Create an actions block with buttons
1313
+ */
1314
+ export function slackActions(blockId: string, ...buttons: SlackButtonElement[]): SlackActionsBlock {
1315
+ return {
1316
+ type: 'actions',
1317
+ block_id: blockId,
1318
+ elements: buttons,
1319
+ }
1320
+ }