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,844 @@
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
+ import { registerTransport } from '../transports.js';
17
+ import { noopLogger } from '../logger.js';
18
+ import { generateRequestId } from '../utils/id.js';
19
+ // =============================================================================
20
+ // Crypto Functions for Signature Verification
21
+ // =============================================================================
22
+ /**
23
+ * Compute HMAC-SHA256 and return the result as a hex string.
24
+ * Uses the Web Crypto API which works in both Node.js and Cloudflare Workers.
25
+ *
26
+ * @param data - The data to sign
27
+ * @param secret - The signing secret
28
+ * @returns A hex-encoded HMAC-SHA256 hash
29
+ */
30
+ export async function computeHmacSha256Hex(data, secret) {
31
+ const encoder = new TextEncoder();
32
+ // Import the secret as a crypto key
33
+ const key = await crypto.subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
34
+ // Sign the data
35
+ const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
36
+ // Convert to hex string
37
+ return Array.from(new Uint8Array(signature))
38
+ .map((b) => b.toString(16).padStart(2, '0'))
39
+ .join('');
40
+ }
41
+ /**
42
+ * Verify a Slack request signature using HMAC-SHA256.
43
+ * Uses the Web Crypto API which works in both Node.js and Cloudflare Workers.
44
+ *
45
+ * @param signature - The x-slack-signature header value (v0=...)
46
+ * @param timestamp - The x-slack-request-timestamp header value
47
+ * @param body - The raw request body
48
+ * @param signingSecret - The Slack signing secret
49
+ * @returns true if the signature is valid, false otherwise
50
+ */
51
+ export async function verifySlackSignature(signature, timestamp, body, signingSecret) {
52
+ // Slack signatures have the format "v0=<hex>"
53
+ if (!signature.startsWith('v0=')) {
54
+ return false;
55
+ }
56
+ // Compute the expected signature
57
+ const baseString = `v0:${timestamp}:${body}`;
58
+ const expectedHmac = await computeHmacSha256Hex(baseString, signingSecret);
59
+ const expectedSignature = `v0=${expectedHmac}`;
60
+ // Constant-time comparison to prevent timing attacks
61
+ return secureCompare(signature, expectedSignature);
62
+ }
63
+ /**
64
+ * Constant-time string comparison to prevent timing attacks.
65
+ * Returns true if both strings are equal, false otherwise.
66
+ */
67
+ function secureCompare(a, b) {
68
+ if (a.length !== b.length) {
69
+ return false;
70
+ }
71
+ let result = 0;
72
+ for (let i = 0; i < a.length; i++) {
73
+ result |= a.charCodeAt(i) ^ b.charCodeAt(i);
74
+ }
75
+ return result === 0;
76
+ }
77
+ // =============================================================================
78
+ // SlackTransport Class
79
+ // =============================================================================
80
+ /**
81
+ * Slack Transport for digital-workers communication
82
+ *
83
+ * @example
84
+ * ```ts
85
+ * const slack = new SlackTransport({
86
+ * botToken: process.env.SLACK_BOT_TOKEN!,
87
+ * signingSecret: process.env.SLACK_SIGNING_SECRET!,
88
+ * })
89
+ *
90
+ * // Send notification to a channel
91
+ * await slack.sendNotification('#engineering', 'Deployment complete!')
92
+ *
93
+ * // Send approval request
94
+ * const result = await slack.sendApprovalRequest('@alice', 'Approve deployment?', {
95
+ * context: { version: '2.1.0' },
96
+ * })
97
+ *
98
+ * // Handle webhook
99
+ * app.post('/slack/events', async (req, res) => {
100
+ * const result = await slack.handleWebhook(req)
101
+ * res.json({ ok: result.success })
102
+ * })
103
+ * ```
104
+ */
105
+ export class SlackTransport {
106
+ config;
107
+ apiBaseUrl;
108
+ logger;
109
+ constructor(config) {
110
+ this.config = {
111
+ ...config,
112
+ transport: 'slack',
113
+ };
114
+ this.apiBaseUrl = config.apiUrl || 'https://slack.com/api';
115
+ this.logger = config.logger ?? noopLogger;
116
+ }
117
+ // ===========================================================================
118
+ // Public Methods
119
+ // ===========================================================================
120
+ /**
121
+ * Send a notification message
122
+ *
123
+ * @param target - Channel (#channel) or user (@user or user ID)
124
+ * @param message - Message text
125
+ * @param options - Additional message options
126
+ */
127
+ async sendNotification(target, message, options = {}) {
128
+ try {
129
+ const channel = await this.resolveTarget(target);
130
+ const blocks = this.formatNotificationBlocks(message, options);
131
+ const thread_ts = options.threadTs;
132
+ const metadata = options.metadata
133
+ ? {
134
+ event_type: 'notification',
135
+ event_payload: options.metadata,
136
+ }
137
+ : undefined;
138
+ const response = await this.postMessage({
139
+ channel,
140
+ text: message,
141
+ blocks,
142
+ ...(thread_ts !== undefined && { thread_ts }),
143
+ ...(metadata !== undefined && { metadata }),
144
+ });
145
+ return {
146
+ success: response.ok,
147
+ transport: 'slack',
148
+ messageId: response.ts,
149
+ metadata: {
150
+ channel: response.channel,
151
+ ts: response.ts,
152
+ },
153
+ };
154
+ }
155
+ catch (error) {
156
+ return {
157
+ success: false,
158
+ transport: 'slack',
159
+ error: error instanceof Error ? error.message : String(error),
160
+ };
161
+ }
162
+ }
163
+ /**
164
+ * Send an approval request with interactive buttons
165
+ *
166
+ * @param target - Channel (#channel) or user (@user or user ID)
167
+ * @param request - Approval request text
168
+ * @param options - Additional options
169
+ */
170
+ async sendApprovalRequest(target, request, options = {}) {
171
+ try {
172
+ const channel = await this.resolveTarget(target);
173
+ const requestId = options.requestId || this.generateRequestId();
174
+ const blocks = this.formatApprovalBlocks(request, {
175
+ ...options,
176
+ requestId,
177
+ });
178
+ const response = await this.postMessage({
179
+ channel,
180
+ text: `Approval Request: ${request}`,
181
+ blocks,
182
+ metadata: {
183
+ event_type: 'approval_request',
184
+ event_payload: {
185
+ requestId,
186
+ context: options.context,
187
+ timeout: options.timeout,
188
+ },
189
+ },
190
+ });
191
+ return {
192
+ success: response.ok,
193
+ transport: 'slack',
194
+ messageId: response.ts,
195
+ metadata: {
196
+ channel: response.channel,
197
+ ts: response.ts,
198
+ requestId,
199
+ },
200
+ };
201
+ }
202
+ catch (error) {
203
+ return {
204
+ success: false,
205
+ transport: 'slack',
206
+ error: error instanceof Error ? error.message : String(error),
207
+ };
208
+ }
209
+ }
210
+ /**
211
+ * Send a question with optional response options
212
+ *
213
+ * @param target - Channel (#channel) or user (@user or user ID)
214
+ * @param question - Question text
215
+ * @param options - Additional options
216
+ */
217
+ async sendQuestion(target, question, options = {}) {
218
+ try {
219
+ const channel = await this.resolveTarget(target);
220
+ const requestId = options.requestId || this.generateRequestId();
221
+ const blocks = this.formatQuestionBlocks(question, {
222
+ ...options,
223
+ requestId,
224
+ });
225
+ const thread_ts = options.threadTs;
226
+ const choices = options.choices;
227
+ const metadata = {
228
+ event_type: 'question',
229
+ event_payload: {
230
+ requestId,
231
+ ...(choices !== undefined && { choices }),
232
+ },
233
+ };
234
+ const response = await this.postMessage({
235
+ channel,
236
+ text: question,
237
+ blocks,
238
+ ...(thread_ts !== undefined && { thread_ts }),
239
+ ...(metadata !== undefined && { metadata }),
240
+ });
241
+ return {
242
+ success: response.ok,
243
+ transport: 'slack',
244
+ messageId: response.ts,
245
+ metadata: {
246
+ channel: response.channel,
247
+ ts: response.ts,
248
+ requestId,
249
+ },
250
+ };
251
+ }
252
+ catch (error) {
253
+ return {
254
+ success: false,
255
+ transport: 'slack',
256
+ error: error instanceof Error ? error.message : String(error),
257
+ };
258
+ }
259
+ }
260
+ /**
261
+ * Handle incoming webhook from Slack (button interactions, etc.)
262
+ *
263
+ * @param request - Webhook request with headers and body
264
+ */
265
+ async handleWebhook(request) {
266
+ // Verify signature using async Web Crypto API
267
+ try {
268
+ const isValid = await this.verifySignatureAsync(request);
269
+ if (!isValid) {
270
+ return {
271
+ success: false,
272
+ error: 'Invalid request signature',
273
+ };
274
+ }
275
+ }
276
+ catch (error) {
277
+ return {
278
+ success: false,
279
+ error: error instanceof Error ? error.message : 'Signature verification failed',
280
+ };
281
+ }
282
+ // Parse payload
283
+ const payload = this.parseWebhookPayload(request);
284
+ if (!payload) {
285
+ return {
286
+ success: false,
287
+ error: 'Invalid webhook payload',
288
+ };
289
+ }
290
+ // Handle block actions (button clicks)
291
+ if (payload.type === 'block_actions' && payload.actions?.length) {
292
+ const action = payload.actions[0];
293
+ if (!action) {
294
+ return {
295
+ success: false,
296
+ error: 'No action found in payload',
297
+ };
298
+ }
299
+ const channelId = payload.channel?.id;
300
+ const messageTs = payload.message?.ts;
301
+ return {
302
+ success: true,
303
+ actionId: action.action_id,
304
+ userId: payload.user.id,
305
+ ...(channelId !== undefined && { channelId }),
306
+ ...(messageTs !== undefined && { messageTs }),
307
+ value: this.parseActionValue(action.value),
308
+ };
309
+ }
310
+ return {
311
+ success: false,
312
+ error: `Unsupported interaction type: ${payload.type}`,
313
+ };
314
+ }
315
+ /**
316
+ * Update an existing message (for approval status updates, etc.)
317
+ *
318
+ * @param channel - Channel ID
319
+ * @param ts - Message timestamp
320
+ * @param text - New text
321
+ * @param blocks - New blocks
322
+ */
323
+ async updateMessage(channel, ts, text, blocks) {
324
+ return this.callApi('chat.update', {
325
+ channel,
326
+ ts,
327
+ text,
328
+ blocks,
329
+ });
330
+ }
331
+ /**
332
+ * Open a DM channel with a user
333
+ *
334
+ * @param userId - User ID to open DM with
335
+ */
336
+ async openDM(userId) {
337
+ const response = await this.callApi('conversations.open', {
338
+ users: userId,
339
+ });
340
+ if (!response.ok || !response.channel?.id) {
341
+ throw new Error(response.error || 'Failed to open DM');
342
+ }
343
+ return response.channel.id;
344
+ }
345
+ /**
346
+ * Look up user by email
347
+ *
348
+ * @param email - User email address
349
+ */
350
+ async lookupUserByEmail(email) {
351
+ try {
352
+ const response = await this.callApi('users.lookupByEmail', {
353
+ email,
354
+ });
355
+ if (!response.ok) {
356
+ return null;
357
+ }
358
+ return response.user?.id || null;
359
+ }
360
+ catch (error) {
361
+ // User not found or other API error - log for debugging
362
+ this.logger.error('lookupUserByEmail failed', error instanceof Error ? error : undefined, {
363
+ email,
364
+ operation: 'lookupUserByEmail',
365
+ });
366
+ return null;
367
+ }
368
+ }
369
+ /**
370
+ * Get the transport handler for registration
371
+ */
372
+ getHandler() {
373
+ return async (payload, config) => {
374
+ const target = Array.isArray(payload.to) ? payload.to[0] : payload.to;
375
+ if (!target) {
376
+ return {
377
+ success: false,
378
+ transport: 'slack',
379
+ error: 'No target specified',
380
+ };
381
+ }
382
+ if (payload.type === 'approval') {
383
+ const context = payload.metadata;
384
+ return this.sendApprovalRequest(target, payload.body, {
385
+ ...(context !== undefined && { context }),
386
+ });
387
+ }
388
+ if (payload.type === 'question') {
389
+ const choices = payload.actions?.map((a) => a.label);
390
+ return this.sendQuestion(target, payload.body, {
391
+ ...(choices !== undefined && { choices }),
392
+ });
393
+ }
394
+ const priority = payload.priority;
395
+ const metadata = payload.metadata;
396
+ return this.sendNotification(target, payload.body, {
397
+ ...(priority !== undefined && { priority }),
398
+ ...(metadata !== undefined && { metadata }),
399
+ });
400
+ };
401
+ }
402
+ /**
403
+ * Register this transport with the transport registry
404
+ */
405
+ register() {
406
+ registerTransport('slack', this.getHandler());
407
+ }
408
+ // ===========================================================================
409
+ // Private Methods
410
+ // ===========================================================================
411
+ /**
412
+ * Resolve target to channel ID
413
+ * - #channel -> channel name lookup
414
+ * - @user -> DM with user
415
+ * - C/U/D ID -> direct use
416
+ */
417
+ async resolveTarget(target) {
418
+ // Already a channel/user ID
419
+ if (/^[CUD][A-Z0-9]+$/.test(target)) {
420
+ return target;
421
+ }
422
+ // Channel reference (#channel)
423
+ if (target.startsWith('#')) {
424
+ // Return channel name, Slack API accepts this
425
+ return target.slice(1);
426
+ }
427
+ // User reference (@user)
428
+ if (target.startsWith('@')) {
429
+ const username = target.slice(1);
430
+ // Try to find user and open DM
431
+ const userId = await this.findUserByName(username);
432
+ if (userId) {
433
+ return this.openDM(userId);
434
+ }
435
+ throw new Error(`User not found: ${username}`);
436
+ }
437
+ // Assume it's a channel name or ID
438
+ return target;
439
+ }
440
+ /**
441
+ * Find user by display name (limited functionality)
442
+ */
443
+ async findUserByName(name) {
444
+ // Note: This would require users:read scope and iterating through users
445
+ // For production, you'd want to implement proper user lookup
446
+ // or use users.lookupByEmail if you have the email
447
+ return null;
448
+ }
449
+ /**
450
+ * Format notification message blocks
451
+ */
452
+ formatNotificationBlocks(message, options) {
453
+ const blocks = [];
454
+ // Add priority indicator for high/urgent
455
+ if (options.priority === 'urgent' || options.priority === 'high') {
456
+ const emoji = options.priority === 'urgent' ? ':rotating_light:' : ':warning:';
457
+ blocks.push({
458
+ type: 'header',
459
+ text: {
460
+ type: 'plain_text',
461
+ text: `${emoji} ${options.priority.toUpperCase()}`,
462
+ emoji: true,
463
+ },
464
+ });
465
+ }
466
+ // Main message
467
+ blocks.push({
468
+ type: 'section',
469
+ text: {
470
+ type: 'mrkdwn',
471
+ text: message,
472
+ },
473
+ });
474
+ // Add context if metadata provided
475
+ if (options.metadata && Object.keys(options.metadata).length > 0) {
476
+ blocks.push({
477
+ type: 'divider',
478
+ });
479
+ blocks.push({
480
+ type: 'context',
481
+ elements: [
482
+ {
483
+ type: 'mrkdwn',
484
+ text: Object.entries(options.metadata)
485
+ .map(([k, v]) => `*${k}:* ${v}`)
486
+ .join(' | '),
487
+ },
488
+ ],
489
+ });
490
+ }
491
+ return blocks;
492
+ }
493
+ /**
494
+ * Format approval request blocks with buttons
495
+ */
496
+ formatApprovalBlocks(request, options) {
497
+ const blocks = [];
498
+ // Header
499
+ blocks.push({
500
+ type: 'header',
501
+ text: {
502
+ type: 'plain_text',
503
+ text: 'Approval Request',
504
+ emoji: true,
505
+ },
506
+ });
507
+ // Request text
508
+ blocks.push({
509
+ type: 'section',
510
+ text: {
511
+ type: 'mrkdwn',
512
+ text: request,
513
+ },
514
+ });
515
+ // Context information
516
+ if (options.context && Object.keys(options.context).length > 0) {
517
+ blocks.push({
518
+ type: 'divider',
519
+ });
520
+ const contextFields = Object.entries(options.context).map(([k, v]) => ({
521
+ type: 'mrkdwn',
522
+ text: `*${k}:*\n${v}`,
523
+ }));
524
+ // Split into chunks of 10 (Slack's limit for fields)
525
+ for (let i = 0; i < contextFields.length; i += 10) {
526
+ blocks.push({
527
+ type: 'section',
528
+ fields: contextFields.slice(i, i + 10),
529
+ });
530
+ }
531
+ }
532
+ // Action buttons
533
+ blocks.push({
534
+ type: 'divider',
535
+ });
536
+ blocks.push({
537
+ type: 'actions',
538
+ block_id: `approval_actions_${options.requestId}`,
539
+ elements: [
540
+ {
541
+ type: 'button',
542
+ text: {
543
+ type: 'plain_text',
544
+ text: options.approveLabel || 'Approve',
545
+ emoji: true,
546
+ },
547
+ style: 'primary',
548
+ action_id: `approve_${options.requestId}`,
549
+ value: JSON.stringify({ action: 'approve', requestId: options.requestId }),
550
+ confirm: {
551
+ title: { type: 'plain_text', text: 'Confirm Approval' },
552
+ text: { type: 'mrkdwn', text: 'Are you sure you want to approve this request?' },
553
+ confirm: { type: 'plain_text', text: 'Approve' },
554
+ deny: { type: 'plain_text', text: 'Cancel' },
555
+ },
556
+ },
557
+ {
558
+ type: 'button',
559
+ text: {
560
+ type: 'plain_text',
561
+ text: options.rejectLabel || 'Reject',
562
+ emoji: true,
563
+ },
564
+ style: 'danger',
565
+ action_id: `reject_${options.requestId}`,
566
+ value: JSON.stringify({ action: 'reject', requestId: options.requestId }),
567
+ confirm: {
568
+ title: { type: 'plain_text', text: 'Confirm Rejection' },
569
+ text: { type: 'mrkdwn', text: 'Are you sure you want to reject this request?' },
570
+ confirm: { type: 'plain_text', text: 'Reject' },
571
+ deny: { type: 'plain_text', text: 'Cancel' },
572
+ style: 'danger',
573
+ },
574
+ },
575
+ ],
576
+ });
577
+ return blocks;
578
+ }
579
+ /**
580
+ * Format question blocks with optional choice buttons
581
+ */
582
+ formatQuestionBlocks(question, options) {
583
+ const blocks = [];
584
+ // Question text
585
+ blocks.push({
586
+ type: 'section',
587
+ text: {
588
+ type: 'mrkdwn',
589
+ text: question,
590
+ },
591
+ });
592
+ // Choice buttons if provided
593
+ if (options.choices && options.choices.length > 0) {
594
+ blocks.push({
595
+ type: 'actions',
596
+ block_id: `question_choices_${options.requestId}`,
597
+ elements: options.choices.slice(0, 5).map((choice, index) => ({
598
+ type: 'button',
599
+ text: {
600
+ type: 'plain_text',
601
+ text: choice,
602
+ emoji: true,
603
+ },
604
+ action_id: `choice_${options.requestId}_${index}`,
605
+ value: JSON.stringify({ choice, requestId: options.requestId }),
606
+ })),
607
+ });
608
+ }
609
+ return blocks;
610
+ }
611
+ /**
612
+ * Post a message to Slack
613
+ */
614
+ async postMessage(message) {
615
+ return this.callApi('chat.postMessage', message);
616
+ }
617
+ /**
618
+ * Call Slack API
619
+ */
620
+ async callApi(method, body) {
621
+ const response = await fetch(`${this.apiBaseUrl}/${method}`, {
622
+ method: 'POST',
623
+ headers: {
624
+ Authorization: `Bearer ${this.config.botToken}`,
625
+ 'Content-Type': 'application/json; charset=utf-8',
626
+ },
627
+ body: JSON.stringify(body),
628
+ });
629
+ if (!response.ok) {
630
+ throw new Error(`Slack API error: ${response.status} ${response.statusText}`);
631
+ }
632
+ const data = (await response.json());
633
+ if (!data.ok) {
634
+ throw new Error(`Slack API error: ${data.error}`);
635
+ }
636
+ return data;
637
+ }
638
+ /**
639
+ * Verify Slack request signature using Web Crypto API.
640
+ * Works in both Node.js and Cloudflare Workers environments.
641
+ */
642
+ async verifySignatureAsync(request) {
643
+ const signature = request.headers['x-slack-signature'];
644
+ const timestamp = request.headers['x-slack-request-timestamp'];
645
+ if (!signature || !timestamp) {
646
+ return false;
647
+ }
648
+ // Check timestamp to prevent replay attacks (5 minutes)
649
+ const now = Math.floor(Date.now() / 1000);
650
+ const requestTimestamp = parseInt(timestamp, 10);
651
+ if (Math.abs(now - requestTimestamp) > 300) {
652
+ return false;
653
+ }
654
+ // Get raw body for verification
655
+ const rawBody = request.rawBody ||
656
+ (typeof request.body === 'string' ? request.body : JSON.stringify(request.body));
657
+ // Use the exported async signature verification function
658
+ return verifySlackSignature(signature, timestamp, rawBody, this.config.signingSecret);
659
+ }
660
+ /**
661
+ * Parse webhook payload
662
+ */
663
+ parseWebhookPayload(request) {
664
+ try {
665
+ if (typeof request.body === 'string') {
666
+ // URL-encoded payload (application/x-www-form-urlencoded)
667
+ if (request.body.startsWith('payload=')) {
668
+ const decoded = decodeURIComponent(request.body.slice(8));
669
+ return JSON.parse(decoded);
670
+ }
671
+ // JSON payload
672
+ return JSON.parse(request.body);
673
+ }
674
+ return request.body;
675
+ }
676
+ catch (error) {
677
+ // Parse error - log for debugging
678
+ this.logger.error('parseWebhookPayload failed', error instanceof Error ? error : undefined, {
679
+ operation: 'parseWebhookPayload',
680
+ bodyType: typeof request.body,
681
+ bodyPreview: typeof request.body === 'string' ? request.body.slice(0, 100) : '[object]',
682
+ });
683
+ return null;
684
+ }
685
+ }
686
+ /**
687
+ * Parse action value (JSON or string)
688
+ */
689
+ parseActionValue(value) {
690
+ try {
691
+ return JSON.parse(value);
692
+ }
693
+ catch {
694
+ // Non-JSON value - this is expected for string values, log at debug level
695
+ this.logger.debug('parseActionValue: value is not JSON, returning as string', {
696
+ operation: 'parseActionValue',
697
+ valuePreview: value.slice(0, 50),
698
+ });
699
+ return value;
700
+ }
701
+ }
702
+ /**
703
+ * Generate unique request ID
704
+ */
705
+ generateRequestId() {
706
+ return generateRequestId('req');
707
+ }
708
+ // ===========================================================================
709
+ // Testing Utilities
710
+ // ===========================================================================
711
+ /**
712
+ * Expose parseWebhookPayload for testing
713
+ * @internal
714
+ */
715
+ parseWebhookPayloadForTesting(request) {
716
+ return this.parseWebhookPayload(request);
717
+ }
718
+ /**
719
+ * Expose parseActionValue for testing
720
+ * @internal
721
+ */
722
+ parseActionValueForTesting(value) {
723
+ return this.parseActionValue(value);
724
+ }
725
+ }
726
+ // =============================================================================
727
+ // Factory Functions
728
+ // =============================================================================
729
+ /**
730
+ * Create a Slack transport instance
731
+ *
732
+ * @example
733
+ * ```ts
734
+ * const slack = createSlackTransport({
735
+ * botToken: process.env.SLACK_BOT_TOKEN!,
736
+ * signingSecret: process.env.SLACK_SIGNING_SECRET!,
737
+ * })
738
+ *
739
+ * await slack.sendNotification('#engineering', 'Hello!')
740
+ * ```
741
+ */
742
+ export function createSlackTransport(config) {
743
+ return new SlackTransport(config);
744
+ }
745
+ /**
746
+ * Create and register a Slack transport handler
747
+ *
748
+ * @example
749
+ * ```ts
750
+ * registerSlackTransport({
751
+ * botToken: process.env.SLACK_BOT_TOKEN!,
752
+ * signingSecret: process.env.SLACK_SIGNING_SECRET!,
753
+ * })
754
+ *
755
+ * // Now 'slack' transport is available via sendViaTransport
756
+ * await sendViaTransport('slack', payload)
757
+ * ```
758
+ */
759
+ export function registerSlackTransport(config) {
760
+ const transport = createSlackTransport(config);
761
+ transport.register();
762
+ return transport;
763
+ }
764
+ // =============================================================================
765
+ // Block Kit Helpers
766
+ // =============================================================================
767
+ /**
768
+ * Create a section block
769
+ */
770
+ export function slackSection(text, options) {
771
+ const block = {
772
+ type: 'section',
773
+ text: {
774
+ type: 'mrkdwn',
775
+ text,
776
+ },
777
+ };
778
+ if (options?.fields) {
779
+ block.fields = options.fields.map((f) => ({
780
+ type: 'mrkdwn',
781
+ text: f,
782
+ }));
783
+ }
784
+ return block;
785
+ }
786
+ /**
787
+ * Create a header block
788
+ */
789
+ export function slackHeader(text) {
790
+ return {
791
+ type: 'header',
792
+ text: {
793
+ type: 'plain_text',
794
+ text,
795
+ emoji: true,
796
+ },
797
+ };
798
+ }
799
+ /**
800
+ * Create a divider block
801
+ */
802
+ export function slackDivider() {
803
+ return { type: 'divider' };
804
+ }
805
+ /**
806
+ * Create a context block
807
+ */
808
+ export function slackContext(...texts) {
809
+ return {
810
+ type: 'context',
811
+ elements: texts.map((text) => ({
812
+ type: 'mrkdwn',
813
+ text,
814
+ })),
815
+ };
816
+ }
817
+ /**
818
+ * Create a button element
819
+ */
820
+ export function slackButton(text, actionId, options) {
821
+ return {
822
+ type: 'button',
823
+ text: {
824
+ type: 'plain_text',
825
+ text,
826
+ emoji: true,
827
+ },
828
+ action_id: actionId,
829
+ ...(options?.value !== undefined && { value: options.value }),
830
+ ...(options?.style !== undefined && { style: options.style }),
831
+ ...(options?.url !== undefined && { url: options.url }),
832
+ };
833
+ }
834
+ /**
835
+ * Create an actions block with buttons
836
+ */
837
+ export function slackActions(blockId, ...buttons) {
838
+ return {
839
+ type: 'actions',
840
+ block_id: blockId,
841
+ elements: buttons,
842
+ };
843
+ }
844
+ //# sourceMappingURL=slack.js.map