digital-workers 2.1.3 → 2.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/CHANGELOG.md +9 -0
- package/README.md +2 -0
- package/dist/actions.d.ts.map +1 -1
- package/dist/actions.js +33 -21
- package/dist/actions.js.map +1 -1
- package/dist/agent-comms.d.ts.map +1 -1
- package/dist/agent-comms.js +36 -25
- package/dist/agent-comms.js.map +1 -1
- package/dist/approve.d.ts +40 -8
- package/dist/approve.d.ts.map +1 -1
- package/dist/approve.js +86 -20
- package/dist/approve.js.map +1 -1
- package/dist/ask.d.ts +38 -7
- package/dist/ask.d.ts.map +1 -1
- package/dist/ask.js +85 -25
- package/dist/ask.js.map +1 -1
- package/dist/browse.d.ts +223 -0
- package/dist/browse.d.ts.map +1 -0
- package/dist/browse.js +392 -0
- package/dist/browse.js.map +1 -0
- package/dist/capability-tiers.js +3 -3
- package/dist/capability-tiers.js.map +1 -1
- package/dist/cascade-context.d.ts +28 -28
- package/dist/client.d.ts +162 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +64 -0
- package/dist/client.js.map +1 -0
- package/dist/decide.d.ts +42 -6
- package/dist/decide.d.ts.map +1 -1
- package/dist/decide.js +54 -11
- package/dist/decide.js.map +1 -1
- package/dist/do.d.ts +36 -7
- package/dist/do.d.ts.map +1 -1
- package/dist/do.js +82 -39
- package/dist/do.js.map +1 -1
- package/dist/error-escalation.d.ts.map +1 -1
- package/dist/error-escalation.js +38 -38
- package/dist/error-escalation.js.map +1 -1
- package/dist/generate.d.ts +48 -7
- package/dist/generate.d.ts.map +1 -1
- package/dist/generate.js +49 -8
- package/dist/generate.js.map +1 -1
- package/dist/goals.d.ts +10 -9
- package/dist/goals.d.ts.map +1 -1
- package/dist/goals.js +30 -24
- package/dist/goals.js.map +1 -1
- package/dist/image.d.ts +189 -0
- package/dist/image.d.ts.map +1 -0
- package/dist/image.js +528 -0
- package/dist/image.js.map +1 -0
- package/dist/index.d.ts +49 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +58 -2
- package/dist/index.js.map +1 -1
- package/dist/is.d.ts +45 -10
- package/dist/is.d.ts.map +1 -1
- package/dist/is.js +56 -21
- package/dist/is.js.map +1 -1
- package/dist/kpis.d.ts +24 -15
- package/dist/kpis.d.ts.map +1 -1
- package/dist/kpis.js +16 -14
- package/dist/kpis.js.map +1 -1
- package/dist/load-balancing.d.ts.map +1 -1
- package/dist/load-balancing.js +124 -38
- package/dist/load-balancing.js.map +1 -1
- package/dist/logger.d.ts +76 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +39 -0
- package/dist/logger.js.map +1 -0
- package/dist/notify.d.ts +38 -9
- package/dist/notify.d.ts.map +1 -1
- package/dist/notify.js +72 -17
- package/dist/notify.js.map +1 -1
- package/dist/role.d.ts +5 -4
- package/dist/role.d.ts.map +1 -1
- package/dist/role.js +13 -10
- package/dist/role.js.map +1 -1
- package/dist/runtime.d.ts +310 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +510 -0
- package/dist/runtime.js.map +1 -0
- package/dist/team.d.ts +11 -6
- package/dist/team.d.ts.map +1 -1
- package/dist/team.js +22 -15
- package/dist/team.js.map +1 -1
- package/dist/transports/email.d.ts +318 -0
- package/dist/transports/email.d.ts.map +1 -0
- package/dist/transports/email.js +779 -0
- package/dist/transports/email.js.map +1 -0
- package/dist/transports/slack.d.ts +515 -0
- package/dist/transports/slack.d.ts.map +1 -0
- package/dist/transports/slack.js +844 -0
- package/dist/transports/slack.js.map +1 -0
- package/dist/transports.d.ts.map +1 -1
- package/dist/transports.js +44 -25
- package/dist/transports.js.map +1 -1
- package/dist/types.d.ts +141 -19
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +5 -0
- package/dist/types.js.map +1 -1
- package/dist/utils/id.d.ts +19 -0
- package/dist/utils/id.d.ts.map +1 -0
- package/dist/utils/id.js +21 -0
- package/dist/utils/id.js.map +1 -0
- package/dist/video.d.ts +203 -0
- package/dist/video.d.ts.map +1 -0
- package/dist/video.js +528 -0
- package/dist/video.js.map +1 -0
- package/dist/worker.d.ts +343 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +698 -0
- package/dist/worker.js.map +1 -0
- package/package.json +32 -14
- package/src/actions.ts +39 -30
- package/src/agent-comms.ts +54 -92
- package/src/approve.ts +91 -20
- package/src/ask.ts +99 -25
- package/src/browse.ts +627 -0
- package/src/capability-tiers.ts +5 -5
- package/src/client.ts +221 -0
- package/src/decide.ts +81 -35
- package/src/do.ts +98 -52
- package/src/error-escalation.ts +55 -67
- package/src/generate.ts +52 -18
- package/src/goals.ts +36 -27
- package/src/image.ts +816 -0
- package/src/index.ts +187 -2
- package/src/is.ts +59 -25
- package/src/kpis.ts +41 -36
- package/src/load-balancing.ts +132 -46
- package/src/logger.ts +93 -0
- package/src/notify.ts +78 -17
- package/src/role.ts +30 -20
- package/src/runtime.ts +796 -0
- package/src/team.ts +24 -19
- package/src/transports/email.ts +1160 -0
- package/src/transports/slack.ts +1320 -0
- package/src/transports.ts +58 -43
- package/src/types.ts +174 -46
- package/src/utils/id.ts +21 -0
- package/src/video.ts +906 -0
- package/src/worker.ts +1007 -0
- package/test/approve.test.ts +305 -0
- package/test/ask.test.ts +274 -0
- package/test/browse.test.ts +361 -0
- package/test/decide.test.ts +252 -0
- package/test/do.test.ts +144 -0
- package/test/error-logging.test.ts +357 -0
- package/test/generate.test.ts +319 -0
- package/test/image.test.ts +398 -0
- package/test/is.test.ts +287 -0
- package/test/load-balancing-safety.test.ts +404 -0
- package/test/notify.test.ts +434 -0
- package/test/primitives.test.ts +320 -0
- package/test/runtime-integration.test.ts +892 -0
- package/test/transports/crypto.test.ts +230 -0
- package/test/transports/email.test.ts +866 -0
- package/test/transports/id-generation.test.ts +91 -0
- package/test/transports/slack.test.ts +760 -0
- package/test/type-safety.test.ts +834 -0
- package/test/types.test.ts +60 -2
- package/test/video.test.ts +530 -0
- package/test/worker.test.ts +1433 -0
- package/tsconfig.json +4 -1
- package/vitest.config.ts +42 -0
- package/wrangler.jsonc +36 -0
- package/.turbo/turbo-build.log +0 -4
- package/LICENSE +0 -21
- package/src/actions.js +0 -436
- package/src/approve.js +0 -234
- package/src/ask.js +0 -226
- package/src/decide.js +0 -244
- package/src/do.js +0 -227
- package/src/generate.js +0 -298
- package/src/goals.js +0 -205
- package/src/index.js +0 -68
- package/src/is.js +0 -317
- package/src/kpis.js +0 -270
- package/src/notify.js +0 -219
- package/src/role.js +0 -110
- package/src/team.js +0 -130
- package/src/transports.js +0 -357
- 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
|
+
}
|