@maroonedsoftware/slack 3.1.2 → 3.2.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/AGENTS.md +41 -11
- package/README.md +81 -22
- package/dist/chunk-22F5RFRD.js +18 -0
- package/dist/chunk-22F5RFRD.js.map +1 -0
- package/dist/client/slack.client.d.ts +12 -0
- package/dist/client/slack.client.d.ts.map +1 -1
- package/dist/client/slack.socket.mode.client.d.ts +104 -0
- package/dist/client/slack.socket.mode.client.d.ts.map +1 -0
- package/dist/index.js +85 -21
- package/dist/index.js.map +1 -1
- package/dist/slack.config.d.ts +32 -2
- package/dist/slack.config.d.ts.map +1 -1
- package/dist/slack.signature.d.ts +7 -3
- package/dist/slack.signature.d.ts.map +1 -1
- package/dist/slack.socket.d.ts +21 -0
- package/dist/slack.socket.d.ts.map +1 -0
- package/dist/socketmode.d.ts +8 -0
- package/dist/socketmode.d.ts.map +1 -0
- package/dist/socketmode.js +198 -0
- package/dist/socketmode.js.map +1 -0
- package/package.json +10 -6
package/AGENTS.md
CHANGED
|
@@ -34,6 +34,8 @@ Runtime dependencies: `@maroonedsoftware/errors`, `@maroonedsoftware/logger`,
|
|
|
34
34
|
- `.` — config, errors, signature verification, handler maps, dispatcher, client.
|
|
35
35
|
- `./comms` — the adapter. Pulls in `@maroonedsoftware/comms`. It lives here, not in `comms`,
|
|
36
36
|
because `comms` must stay channel-free; see the root AGENTS.md.
|
|
37
|
+
- `./socketmode` — `SocketModeClient` over a caller-supplied socket. No extra dependencies; kept
|
|
38
|
+
off the root barrel so an HTTP-only app never loads it.
|
|
37
39
|
|
|
38
40
|
**Not a dependency: `koa`.** Your route parses the request and calls the dispatcher.
|
|
39
41
|
|
|
@@ -41,11 +43,12 @@ Runtime dependencies: `@maroonedsoftware/errors`, `@maroonedsoftware/logger`,
|
|
|
41
43
|
|
|
42
44
|
### `.` — config and errors
|
|
43
45
|
|
|
44
|
-
| Export | Kind | Shape
|
|
45
|
-
| -------------- | -------------------------- |
|
|
46
|
-
| `SlackConfig` | interface + abstract class | `{ botToken, signingSecret
|
|
47
|
-
| `
|
|
48
|
-
| `
|
|
46
|
+
| Export | Kind | Shape | Notes |
|
|
47
|
+
| -------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
|
|
48
|
+
| `SlackConfig` | interface + abstract class | `{ botToken, signingSecret?, appToken?, incomingWebhookUrl?, signatureMaxAgeSeconds?, requestTimeoutMs?, apiBaseUrl?, fetch? }` | Declaration-merged so one symbol is type and DI token. |
|
|
49
|
+
| `SlackFetch` | type | `@slack/web-api`'s `FetchFunction` | The global `fetch` satisfies it. |
|
|
50
|
+
| `SlackError` | class | `extends ServerkitError` | — |
|
|
51
|
+
| `IsSlackError` | type guard | `(error: unknown) => error is SlackError` | — |
|
|
49
52
|
|
|
50
53
|
### `.` — signature verification
|
|
51
54
|
|
|
@@ -83,12 +86,12 @@ guard.
|
|
|
83
86
|
|
|
84
87
|
### `.` — client
|
|
85
88
|
|
|
86
|
-
| Export | Kind | Shape | Notes
|
|
87
|
-
| ---------------------------------- | -------- | --------------------------------------------------------------- |
|
|
88
|
-
| `SlackClient` | class | `postMessage`, `postWebhook`, …
|
|
89
|
-
| `adaptLogger` | function | Bridges `@maroonedsoftware/logger` to `@slack/web-api`'s logger | —
|
|
90
|
-
| `redactSlackUrl` | function | Strips the token from a webhook URL before logging | Use it before logging any webhook URL.
|
|
91
|
-
| `SLACK_DEFAULT_REQUEST_TIMEOUT_MS` | constant | — | —
|
|
89
|
+
| Export | Kind | Shape | Notes |
|
|
90
|
+
| ---------------------------------- | -------- | --------------------------------------------------------------- | ----------------------------------------------------------------------- |
|
|
91
|
+
| `SlackClient` | class | `postMessage`, `postWebhook`, `openSocketModeUrl`, … | Over `@slack/web-api`. Every call goes through `config.fetch` when set. |
|
|
92
|
+
| `adaptLogger` | function | Bridges `@maroonedsoftware/logger` to `@slack/web-api`'s logger | — |
|
|
93
|
+
| `redactSlackUrl` | function | Strips the token from a webhook URL before logging | Use it before logging any webhook URL. |
|
|
94
|
+
| `SLACK_DEFAULT_REQUEST_TIMEOUT_MS` | constant | — | — |
|
|
92
95
|
|
|
93
96
|
### `./comms`
|
|
94
97
|
|
|
@@ -99,6 +102,19 @@ guard.
|
|
|
99
102
|
| `dispatchSlackCommand` | function | `(router, client, payload) => Promise<void>` | Replies via `response_url` when present. |
|
|
100
103
|
| `dispatchSlackInteraction` | function | `(router, client, payload) => Promise<void>` | **Only `block_actions`** is normalised. |
|
|
101
104
|
|
|
105
|
+
### `./socketmode`
|
|
106
|
+
|
|
107
|
+
| Export | Kind | Shape | Notes |
|
|
108
|
+
| ---------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------- |
|
|
109
|
+
| `SocketModeClient` | class | `new SocketModeClient({ openUrl, connect, handlers, logger, onError?, backoff? })`, `start()`, `stop()`, `isReady` | Acks every envelope before its handler runs. |
|
|
110
|
+
| `SocketLike` | interface | `send(text)`, `close(code?, reason?)`, `onMessage(listener)`, `onClose(listener)` | The whole transport contract. Same shape as discord's `SocketLike`. |
|
|
111
|
+
| `SocketConnect` | type | `(url) => SocketLike \| Promise<SocketLike>` | — |
|
|
112
|
+
| `SocketModeHandlers` | type | `{ onEventsApi?, onSlashCommand?, onInteractive? }` | Each gets `(payload, SocketModeEnvelopeMeta)`. |
|
|
113
|
+
| `SocketModeEnvelopeMeta` | type | `{ envelopeId, retryAttempt?, retryReason? }` | — |
|
|
114
|
+
| `SocketModeClientOptions` | type | — | — |
|
|
115
|
+
| `SOCKET_MODE_DEFAULT_BACKOFF_INITIAL_MS` | constant | `1_000` | — |
|
|
116
|
+
| `SOCKET_MODE_DEFAULT_BACKOFF_MAX_MS` | constant | `30_000` | — |
|
|
117
|
+
|
|
102
118
|
## Canonical usage
|
|
103
119
|
|
|
104
120
|
```typescript
|
|
@@ -149,6 +165,9 @@ await dispatchSlackCommand(router, client, payload);
|
|
|
149
165
|
- Ack within 3 seconds and do slow work in a job. Slack times out and retries.
|
|
150
166
|
- Never log a webhook URL without `redactSlackUrl` — the token is in the path.
|
|
151
167
|
- Import `./comms` functions from `@maroonedsoftware/slack/comms`, never from the root.
|
|
168
|
+
- Import `SocketModeClient` from `@maroonedsoftware/slack/socketmode`. Pass
|
|
169
|
+
`openUrl: () => slackClient.openSocketModeUrl()`; never cache a Socket Mode URL, since each is
|
|
170
|
+
single-use.
|
|
152
171
|
|
|
153
172
|
## Gotchas
|
|
154
173
|
|
|
@@ -164,7 +183,15 @@ await dispatchSlackCommand(router, client, payload);
|
|
|
164
183
|
`http` would be misrouted.
|
|
165
184
|
- **Bot messages are filtered out** of the comms path (`bot_id`, `subtype: 'bot_message'`) to avoid
|
|
166
185
|
loops. The native event handlers see them.
|
|
186
|
+
- **Socket Mode acks are always empty.** The client acks before the handler runs, so a handler's
|
|
187
|
+
return value is discarded; `view_submission` `response_action` errors cannot be sent that way.
|
|
188
|
+
Reply through `response_url` or the Web API.
|
|
167
189
|
- **`verifySlackSignature` throws; `SlackSignaturePolicy` denies.** Same logic, two shapes.
|
|
190
|
+
- **`signingSecret` is optional** because a Socket Mode app never verifies a request. Without it,
|
|
191
|
+
verification fails closed with reason `missing_signing_secret`; it never checks against an empty key.
|
|
192
|
+
- **`openSocketModeUrl` uses `appToken`, not `botToken`.** Each URL it returns is single-use.
|
|
193
|
+
- **A transport failure in `postWebhook` has no `cause`.** The cause would quote the secret URL;
|
|
194
|
+
the redacted reason is in `internalDetails.reason`.
|
|
168
195
|
- **The signature has a max-age replay window.** Clock skew on your host produces spurious
|
|
169
196
|
verification failures.
|
|
170
197
|
- **`@slack/web-api` is a hard dependency**, unlike `comms` and `cache`. Installing this package
|
|
@@ -186,6 +213,9 @@ src/
|
|
|
186
213
|
client/slack.client.ts SlackClient
|
|
187
214
|
client/slack.logger.adapter.ts adaptLogger, redactSlackUrl
|
|
188
215
|
comms.ts Subpath entry — notifier, render, and the three dispatch functions
|
|
216
|
+
slack.socket.ts SocketLike, SocketConnect
|
|
217
|
+
client/slack.socket.mode.client.ts SocketModeClient
|
|
218
|
+
socketmode.ts Subpath entry — SocketLike and SocketModeClient
|
|
189
219
|
```
|
|
190
220
|
|
|
191
221
|
Tests are in `tests/`, mirroring `src/`.
|
package/README.md
CHANGED
|
@@ -15,19 +15,19 @@ pnpm add @maroonedsoftware/slack
|
|
|
15
15
|
|
|
16
16
|
## Exports
|
|
17
17
|
|
|
18
|
-
| Symbol | Purpose
|
|
19
|
-
| ------------------------------------ |
|
|
20
|
-
| `SlackConfig` | Abstract `@Injectable()` token; carries `botToken`, `signingSecret`,
|
|
21
|
-
| `SlackClient` | Wraps `@slack/web-api`'s `WebClient`; routes its diagnostics through ServerKit's `Logger`. Methods: `postMessage`, `updateMessage`, `deleteMessage`, `openView`, `postWebhook`. Underlying SDK reachable at `.web`. |
|
|
22
|
-
| `SlackDispatcher` | Three-method service: `dispatchEvent`, `dispatchCommand`, `dispatchInteraction`.
|
|
23
|
-
| `SlackEventHandlerMap` | `Map<eventType, SlackEventHandler>` — register one handler per Slack event type (`app_mention`, `message`, …).
|
|
24
|
-
| `SlackCommandHandlerMap` | `Map<commandKeyword, SlackCommandHandler>` — register one handler per slash command (`/deploy`, …).
|
|
25
|
-
| `SlackInteractionHandlerMap` | `Map<routingKey, SlackInteractionHandler>` — keys are `${type}:${identifier}`; see [interaction routing](#interaction-routing).
|
|
26
|
-
| `SlackError` | `ServerkitError` subclass for non-HTTP domain failures (signature mismatch, webhook POST failed, …).
|
|
27
|
-
| `verifySlackSignature(input)` | Pure helper that validates Slack's v0 HMAC scheme + replay window. No request/context coupling.
|
|
28
|
-
| `SlackSignaturePolicy` | `@maroonedsoftware/policies` form of `verifySlackSignature` (registered under `SLACK_SIGNATURE_POLICY`). Delegates to the helper but answers as a `PolicyResult`, so it slots into ServerKit's policy pipeline.
|
|
29
|
-
| `interactionRouteKey(payload)` | Helper that produces the `SlackInteractionHandlerMap` key for a given payload.
|
|
30
|
-
| `slackEventIdempotencyKey(envelope)` | Pure helper that derives a stable de-dup key (`slack:event:{team_id}:{event_id}`) for an `event_callback` envelope.
|
|
18
|
+
| Symbol | Purpose |
|
|
19
|
+
| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
20
|
+
| `SlackConfig` | Abstract `@Injectable()` token; carries `botToken`, optional `signingSecret`, `appToken`, `incomingWebhookUrl`, `signatureMaxAgeSeconds`, `requestTimeoutMs`, `apiBaseUrl`, `fetch`. Consumer registers a concrete value. |
|
|
21
|
+
| `SlackClient` | Wraps `@slack/web-api`'s `WebClient`; routes its diagnostics through ServerKit's `Logger`. Methods: `postMessage`, `updateMessage`, `deleteMessage`, `openView`, `postWebhook`, `openSocketModeUrl`. Underlying SDK reachable at `.web`. |
|
|
22
|
+
| `SlackDispatcher` | Three-method service: `dispatchEvent`, `dispatchCommand`, `dispatchInteraction`. |
|
|
23
|
+
| `SlackEventHandlerMap` | `Map<eventType, SlackEventHandler>` — register one handler per Slack event type (`app_mention`, `message`, …). |
|
|
24
|
+
| `SlackCommandHandlerMap` | `Map<commandKeyword, SlackCommandHandler>` — register one handler per slash command (`/deploy`, …). |
|
|
25
|
+
| `SlackInteractionHandlerMap` | `Map<routingKey, SlackInteractionHandler>` — keys are `${type}:${identifier}`; see [interaction routing](#interaction-routing). |
|
|
26
|
+
| `SlackError` | `ServerkitError` subclass for non-HTTP domain failures (signature mismatch, webhook POST failed, …). |
|
|
27
|
+
| `verifySlackSignature(input)` | Pure helper that validates Slack's v0 HMAC scheme + replay window. No request/context coupling. |
|
|
28
|
+
| `SlackSignaturePolicy` | `@maroonedsoftware/policies` form of `verifySlackSignature` (registered under `SLACK_SIGNATURE_POLICY`). Delegates to the helper but answers as a `PolicyResult`, so it slots into ServerKit's policy pipeline. |
|
|
29
|
+
| `interactionRouteKey(payload)` | Helper that produces the `SlackInteractionHandlerMap` key for a given payload. |
|
|
30
|
+
| `slackEventIdempotencyKey(envelope)` | Pure helper that derives a stable de-dup key (`slack:event:{team_id}:{event_id}`) for an `event_callback` envelope. |
|
|
31
31
|
|
|
32
32
|
## Configuration
|
|
33
33
|
|
|
@@ -51,16 +51,22 @@ registry.register(SlackConfig).useValue(slackConfig);
|
|
|
51
51
|
"signingSecret": "...",
|
|
52
52
|
"incomingWebhookUrl": "https://hooks.slack.com/services/...", // optional
|
|
53
53
|
"signatureMaxAgeSeconds": 300, // optional
|
|
54
|
+
"appToken": "xapp-...", // optional, Socket Mode only
|
|
55
|
+
"apiBaseUrl": "https://slack.com/api/", // optional
|
|
54
56
|
},
|
|
55
57
|
}
|
|
56
58
|
```
|
|
57
59
|
|
|
58
|
-
| Field | Required | Used by
|
|
59
|
-
| ------------------------ | -------- |
|
|
60
|
-
| `botToken` | yes | `SlackClient` constructor — passed to `WebClient`.
|
|
61
|
-
| `signingSecret` |
|
|
62
|
-
| `
|
|
63
|
-
| `
|
|
60
|
+
| Field | Required | Used by |
|
|
61
|
+
| ------------------------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
62
|
+
| `botToken` | yes | `SlackClient` constructor — passed to `WebClient`. |
|
|
63
|
+
| `signingSecret` | no | Your signature verifier (Slack signs requests with this secret). Needed only when Slack calls you over HTTP; verification fails closed without it. |
|
|
64
|
+
| `appToken` | no | `SlackClient.openSocketModeUrl`: the `xapp-` token with `connections:write`. |
|
|
65
|
+
| `apiBaseUrl` | no | Forwarded to `WebClient` as `slackApiUrl`. |
|
|
66
|
+
| `requestTimeoutMs` | no | Timeout for `postWebhook` (default 10s). |
|
|
67
|
+
| `fetch` | no | The transport for every outbound call. See [bringing your own fetch](#bringing-your-own-fetch). |
|
|
68
|
+
| `incomingWebhookUrl` | no | `SlackClient.postWebhook` default URL when no per-call URL is supplied. |
|
|
69
|
+
| `signatureMaxAgeSeconds` | no | Replay-protection window for your signature verifier (default `300`). |
|
|
64
70
|
|
|
65
71
|
## Sending messages
|
|
66
72
|
|
|
@@ -79,7 +85,19 @@ await slack.postWebhook({ text: 'still working on it…' }, payload.response_url
|
|
|
79
85
|
await slack.web.users.info({ user: 'U123' });
|
|
80
86
|
```
|
|
81
87
|
|
|
82
|
-
`postWebhook` throws `SlackError` if neither `config.incomingWebhookUrl` nor an explicit URL is provided,
|
|
88
|
+
`postWebhook` throws `SlackError` if neither `config.incomingWebhookUrl` nor an explicit URL is provided, if the HTTP response is non-2xx, or if the POST never reaches Slack. That last error carries the transport's reason with the URL's secret segment redacted, and no `cause`, because the cause would quote the URL.
|
|
89
|
+
|
|
90
|
+
### Bringing your own fetch
|
|
91
|
+
|
|
92
|
+
`SlackConfig.fetch` routes every outbound call through a transport the caller owns: an allowlisting host, a proxy, or a test. It covers the `WebClient` (as the SDK's own `fetch` option), `postWebhook`, and `openSocketModeUrl`. It defaults to the global `fetch`, and its type, `SlackFetch`, is the SDK's `FetchFunction`, so the global `fetch` satisfies it.
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
registry.register(SlackConfig).useValue({ ...appConfig.getAs<SlackConfig>('slack'), fetch: host.fetch });
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### The app token
|
|
99
|
+
|
|
100
|
+
Socket Mode authenticates with an app-level token (`xapp-...`, scope `connections:write`), not the bot token. `openSocketModeUrl()` trades it for a single-use WebSocket URL via `apps.connections.open`, over the same `fetch` and base URL. Call it again for every reconnect. It throws `SlackError` when `appToken` is not set, or when Slack does not hand back a URL.
|
|
83
101
|
|
|
84
102
|
## Receiving Slack requests
|
|
85
103
|
|
|
@@ -301,9 +319,10 @@ try {
|
|
|
301
319
|
|
|
302
320
|
What the helper enforces:
|
|
303
321
|
|
|
322
|
+
1. A signing secret is configured. Without one the helper throws with reason `missing_signing_secret`, so an HTTP route on a Socket Mode config fails closed.
|
|
304
323
|
1. `X-Slack-Request-Timestamp` is present and an integer.
|
|
305
|
-
|
|
306
|
-
|
|
324
|
+
1. `|now - timestamp| <= maxAgeSeconds` (default 300) — replay protection.
|
|
325
|
+
1. `X-Slack-Signature` matches `v0=` + `HMAC-SHA256(signingSecret, "v0:{timestamp}:{rawBody}")` as hex, compared with `crypto.timingSafeEqual`.
|
|
307
326
|
|
|
308
327
|
On any failure the helper throws `SlackError` with `internalDetails.reason` set to a `SlackSignatureFailureReason` code. Map to HTTP 401 at the route boundary.
|
|
309
328
|
|
|
@@ -332,6 +351,45 @@ if (isPolicyResultDenied(result)) throw httpError(401).withInternalDetails(resul
|
|
|
332
351
|
|
|
333
352
|
The context (`rawBody` + a case-insensitive `getHeader` + `options`) is structurally compatible with `@maroonedsoftware/koa`'s `SignaturePolicyContext<SlackSignatureOptions>`, so the koa `requireSignature` middleware can drive this policy when it's registered under the signature policy name — no koa dependency in this package.
|
|
334
353
|
|
|
354
|
+
## Socket Mode
|
|
355
|
+
|
|
356
|
+
`@maroonedsoftware/slack/socketmode` receives events, slash commands, and interactive payloads over a Slack Socket Mode WebSocket, for an app with no public HTTP endpoint. The caller supplies the socket, so the package never opens a connection of its own.
|
|
357
|
+
|
|
358
|
+
```ts
|
|
359
|
+
import { SlackClient } from '@maroonedsoftware/slack';
|
|
360
|
+
import { SocketModeClient } from '@maroonedsoftware/slack/socketmode';
|
|
361
|
+
|
|
362
|
+
const slack = container.get(SlackClient); // SlackConfig.appToken must be set
|
|
363
|
+
|
|
364
|
+
const socketMode = new SocketModeClient({
|
|
365
|
+
openUrl: () => slack.openSocketModeUrl(),
|
|
366
|
+
connect: url => host.socket(url), // anything satisfying SocketLike
|
|
367
|
+
handlers: {
|
|
368
|
+
onEventsApi: (event, { envelopeId }) => dispatcher.dispatchEvent(event),
|
|
369
|
+
onSlashCommand: payload => slack.postWebhook({ text: 'on it' }, payload.response_url),
|
|
370
|
+
onInteractive: payload => dispatcher.dispatchInteraction(payload),
|
|
371
|
+
},
|
|
372
|
+
logger,
|
|
373
|
+
onError: error => logger.error('Socket Mode stopped', error),
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
await socketMode.start();
|
|
377
|
+
// on shutdown
|
|
378
|
+
socketMode.stop();
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
`SocketLike` is the whole transport contract: `send(text)`, `close(code?, reason?)`, `onMessage(listener)`, and `onClose(listener)`. `connect` may answer with the socket or a promise of one.
|
|
382
|
+
|
|
383
|
+
How it behaves:
|
|
384
|
+
|
|
385
|
+
- **Every envelope is acked the moment it arrives**, before its handler runs, so a slow handler never misses Slack's 3-second window. The ack is always empty, so a handler cannot answer through it: reply with the payload's `response_url` or the Web API. That rules out `view_submission` `response_action` errors, which only travel in an ack.
|
|
386
|
+
- Handlers get the payload plus `{ envelopeId, retryAttempt?, retryReason? }`. A handler that throws or rejects is logged, and the client carries on.
|
|
387
|
+
- `hello` sets `isReady`.
|
|
388
|
+
- `disconnect` with `refresh_requested` or `warning` opens a fresh URL and moves to it before closing the old socket. `link_disabled` stops the client and calls `onError`.
|
|
389
|
+
- Any other close reconnects with backoff: 1s doubling to 30s (configurable through `backoff`), reset by the next `hello`.
|
|
390
|
+
- `start()` rejects if the first URL or socket cannot be opened. Later reconnects retry instead.
|
|
391
|
+
- `stop()` closes the socket and cancels any pending reconnect. The client never reconnects after it.
|
|
392
|
+
|
|
335
393
|
## Use with `@maroonedsoftware/comms`
|
|
336
394
|
|
|
337
395
|
The `@maroonedsoftware/slack/comms` subpath adapts this package to the channel-agnostic
|
|
@@ -369,6 +427,7 @@ router.post('/slack/commands', async ctx => {
|
|
|
369
427
|
## Limitations
|
|
370
428
|
|
|
371
429
|
- v1 supports a single workspace via the bot token in `SlackConfig`. Multi-workspace OAuth install is out of scope.
|
|
430
|
+
- Socket Mode acks are always empty (see [Socket Mode](#socket-mode)).
|
|
372
431
|
|
|
373
432
|
## License
|
|
374
433
|
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import {
|
|
2
|
+
__name
|
|
3
|
+
} from "./chunk-7QVYU63E.js";
|
|
4
|
+
|
|
5
|
+
// src/slack.error.ts
|
|
6
|
+
import { ServerkitError } from "@maroonedsoftware/errors";
|
|
7
|
+
var SlackError = class extends ServerkitError {
|
|
8
|
+
static {
|
|
9
|
+
__name(this, "SlackError");
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
var IsSlackError = /* @__PURE__ */ __name((error) => error instanceof SlackError, "IsSlackError");
|
|
13
|
+
|
|
14
|
+
export {
|
|
15
|
+
SlackError,
|
|
16
|
+
IsSlackError
|
|
17
|
+
};
|
|
18
|
+
//# sourceMappingURL=chunk-22F5RFRD.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/slack.error.ts"],"sourcesContent":["import { ServerkitError } from '@maroonedsoftware/errors';\n\n/**\n * Domain error raised by the Slack package for non-HTTP failures (e.g.\n * incoming-webhook POST failed, unknown handler dispatch).\n *\n * Extends {@link ServerkitError} so `errorMiddleware` renders a 500 with\n * `{ message, details }` if one of these escapes a route handler. Inside\n * route handlers, throw `httpError(...)` directly for status-coded responses.\n */\nexport class SlackError extends ServerkitError {}\n\n/**\n * Type guard for {@link SlackError}. Narrows `unknown` to `SlackError` so\n * `details`, `internalDetails`, and the chainable setters are accessible\n * without further checks. Returns `true` for any subclass.\n */\nexport const IsSlackError = (error: unknown): error is SlackError => error instanceof SlackError;\n"],"mappings":";;;;;AAAA,SAASA,sBAAsB;AAUxB,IAAMC,aAAN,cAAyBC,eAAAA;EAVhC,OAUgCA;;;AAAgB;AAOzC,IAAMC,eAAe,wBAACC,UAAwCA,iBAAiBH,YAA1D;","names":["ServerkitError","SlackError","ServerkitError","IsSlackError","error"]}
|
|
@@ -48,7 +48,11 @@ export declare class SlackClient {
|
|
|
48
48
|
private readonly logger;
|
|
49
49
|
/** Underlying `@slack/web-api` client. */
|
|
50
50
|
readonly web: WebClient;
|
|
51
|
+
/** Web API client authenticated with the app token, built on first use by {@link openSocketModeUrl}. */
|
|
52
|
+
private appWeb?;
|
|
51
53
|
constructor(config: SlackConfig, logger: Logger);
|
|
54
|
+
/** Options shared by every `WebClient` this class builds, passing only what the config sets. */
|
|
55
|
+
private webClientOptions;
|
|
52
56
|
/** Posts a message via `chat.postMessage`. */
|
|
53
57
|
postMessage(args: ChatPostMessageArguments): Promise<ChatPostMessageResponse>;
|
|
54
58
|
/** Updates a message via `chat.update`. */
|
|
@@ -65,5 +69,13 @@ export declare class SlackClient {
|
|
|
65
69
|
* @throws {@link SlackError} if no URL is available or the response is non-2xx.
|
|
66
70
|
*/
|
|
67
71
|
postWebhook(payload: IncomingWebhookPayload, url?: string): Promise<void>;
|
|
72
|
+
/**
|
|
73
|
+
* Opens a Socket Mode connection slot via `apps.connections.open` and returns the WebSocket URL
|
|
74
|
+
* to connect to. Authenticates with `appToken`, not the bot token, over the same `fetch` and base
|
|
75
|
+
* URL as every other call. Each URL is single-use, so call this again for every reconnect.
|
|
76
|
+
*
|
|
77
|
+
* @throws {@link SlackError} if `appToken` is not configured or Slack does not hand back a URL.
|
|
78
|
+
*/
|
|
79
|
+
openSocketModeUrl(): Promise<string>;
|
|
68
80
|
}
|
|
69
81
|
//# sourceMappingURL=slack.client.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"slack.client.d.ts","sourceRoot":"","sources":["../../src/client/slack.client.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAC3C,OAAO,KAAK,
|
|
1
|
+
{"version":3,"file":"slack.client.d.ts","sourceRoot":"","sources":["../../src/client/slack.client.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAC3C,OAAO,KAAK,EAEV,wBAAwB,EACxB,uBAAuB,EACvB,mBAAmB,EACnB,kBAAkB,EAClB,mBAAmB,EACnB,kBAAkB,EAClB,kBAAkB,EAClB,iBAAiB,EAClB,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAAE,MAAM,EAAE,MAAM,0BAA0B,CAAC;AAClD,OAAO,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAIjD,gFAAgF;AAChF,eAAO,MAAM,gCAAgC,QAAS,CAAC;AAEvD;;;;GAIG;AACH,eAAO,MAAM,cAAc,GAAI,KAAK,MAAM,KAAG,MAS5C,CAAC;AAEF;;;;GAIG;AACH,MAAM,MAAM,sBAAsB,GAAG;IACnC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,OAAO,EAAE,CAAC;IACnB,WAAW,CAAC,EAAE,OAAO,EAAE,CAAC;IACxB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,aAAa,CAAC,EAAE,YAAY,GAAG,WAAW,CAAC;IAC3C,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB,CAAC;AAEF;;;;;;;;;;;;;;;GAeG;AACH,qBACa,WAAW;IAQpB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,MAAM;IARzB,0CAA0C;IAC1C,QAAQ,CAAC,GAAG,EAAE,SAAS,CAAC;IAExB,wGAAwG;IACxG,OAAO,CAAC,MAAM,CAAC,CAAY;gBAGR,MAAM,EAAE,WAAW,EACnB,MAAM,EAAE,MAAM;IAKjC,gGAAgG;IAChG,OAAO,CAAC,gBAAgB;IAQxB,8CAA8C;IAC9C,WAAW,CAAC,IAAI,EAAE,wBAAwB,GAAG,OAAO,CAAC,uBAAuB,CAAC;IAI7E,2CAA2C;IAC3C,aAAa,CAAC,IAAI,EAAE,mBAAmB,GAAG,OAAO,CAAC,kBAAkB,CAAC;IAIrE,2CAA2C;IAC3C,aAAa,CAAC,IAAI,EAAE,mBAAmB,GAAG,OAAO,CAAC,kBAAkB,CAAC;IAIrE,2CAA2C;IAC3C,QAAQ,CAAC,IAAI,EAAE,kBAAkB,GAAG,OAAO,CAAC,iBAAiB,CAAC;IAI9D;;;;;;OAMG;IACG,WAAW,CAAC,OAAO,EAAE,sBAAsB,EAAE,GAAG,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAgC/E;;;;;;OAMG;IACG,iBAAiB,IAAI,OAAO,CAAC,MAAM,CAAC;CAyB3C"}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { Logger } from '@maroonedsoftware/logger';
|
|
2
|
+
import type { SlackCommandPayload } from '../slack.command.handler.js';
|
|
3
|
+
import { SlackError } from '../slack.error.js';
|
|
4
|
+
import type { SlackEventCallback } from '../slack.event.handler.js';
|
|
5
|
+
import type { SlackInteractionPayload } from '../slack.interaction.handler.js';
|
|
6
|
+
import type { SocketConnect } from '../slack.socket.js';
|
|
7
|
+
/** Default first reconnect delay (ms) after an unexpected close. */
|
|
8
|
+
export declare const SOCKET_MODE_DEFAULT_BACKOFF_INITIAL_MS = 1000;
|
|
9
|
+
/** Default ceiling (ms) the reconnect delay doubles up to. */
|
|
10
|
+
export declare const SOCKET_MODE_DEFAULT_BACKOFF_MAX_MS = 30000;
|
|
11
|
+
/** Envelope metadata handed to every Socket Mode handler alongside the payload. */
|
|
12
|
+
export type SocketModeEnvelopeMeta = {
|
|
13
|
+
/** The envelope id, already acknowledged by the time a handler runs. */
|
|
14
|
+
envelopeId: string;
|
|
15
|
+
/** How many times Slack has sent this envelope before (Events API only). */
|
|
16
|
+
retryAttempt?: number;
|
|
17
|
+
/** Why Slack is retrying (Events API only). */
|
|
18
|
+
retryReason?: string;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Handlers for the three Socket Mode payload types. Each runs **after** the envelope has been
|
|
22
|
+
* acknowledged, so a slow handler never misses Slack's 3-second window, and none can shape the ack:
|
|
23
|
+
* reply through the payload's `response_url` or the Web API instead. A handler that throws or
|
|
24
|
+
* rejects is logged and does not stop the client.
|
|
25
|
+
*/
|
|
26
|
+
export type SocketModeHandlers = {
|
|
27
|
+
/** An Events API delivery (`event_callback`). */
|
|
28
|
+
onEventsApi?: (payload: SlackEventCallback, meta: SocketModeEnvelopeMeta) => unknown;
|
|
29
|
+
/** A slash command. */
|
|
30
|
+
onSlashCommand?: (payload: SlackCommandPayload, meta: SocketModeEnvelopeMeta) => unknown;
|
|
31
|
+
/** An interactive payload (`block_actions`, `view_submission`, `shortcut`, …). */
|
|
32
|
+
onInteractive?: (payload: SlackInteractionPayload, meta: SocketModeEnvelopeMeta) => unknown;
|
|
33
|
+
};
|
|
34
|
+
/** Options for {@link SocketModeClient}. */
|
|
35
|
+
export type SocketModeClientOptions = {
|
|
36
|
+
/** Returns a fresh, single-use WebSocket URL. Normally `() => slackClient.openSocketModeUrl()`. */
|
|
37
|
+
openUrl: () => Promise<string>;
|
|
38
|
+
/** Opens the caller's socket to a URL `openUrl` returned. */
|
|
39
|
+
connect: SocketConnect;
|
|
40
|
+
/** Where each payload type goes. */
|
|
41
|
+
handlers: SocketModeHandlers;
|
|
42
|
+
logger: Logger;
|
|
43
|
+
/** Called once when the client stops for good on its own, e.g. Slack disabling the link. */
|
|
44
|
+
onError?: (error: SlackError) => void;
|
|
45
|
+
/** Reconnect backoff after an unexpected close: `initialMs` doubling up to `maxMs`. */
|
|
46
|
+
backoff?: {
|
|
47
|
+
initialMs?: number;
|
|
48
|
+
maxMs?: number;
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* A Slack Socket Mode client over a socket the caller supplies. It never opens a connection of its
|
|
53
|
+
* own: `openUrl` fetches the URL (through whatever `fetch` the caller configured), and `connect`
|
|
54
|
+
* opens the socket.
|
|
55
|
+
*
|
|
56
|
+
* - Every envelope is acknowledged with `{ envelope_id }` the moment it arrives, before its handler
|
|
57
|
+
* runs.
|
|
58
|
+
* - `hello` marks the client ready.
|
|
59
|
+
* - `disconnect` with `refresh_requested` or `warning` opens a fresh URL and moves over to it before
|
|
60
|
+
* closing the old socket. `link_disabled` stops the client and reports through `onError`.
|
|
61
|
+
* - Any other close reconnects with exponential backoff, until {@link stop}.
|
|
62
|
+
*
|
|
63
|
+
* @example
|
|
64
|
+
* ```ts
|
|
65
|
+
* const socketMode = new SocketModeClient({
|
|
66
|
+
* openUrl: () => slack.openSocketModeUrl(),
|
|
67
|
+
* connect: url => host.socket(url),
|
|
68
|
+
* handlers: { onSlashCommand: payload => slack.postWebhook({ text: 'on it' }, payload.response_url) },
|
|
69
|
+
* logger,
|
|
70
|
+
* });
|
|
71
|
+
* await socketMode.start();
|
|
72
|
+
* ```
|
|
73
|
+
*/
|
|
74
|
+
export declare class SocketModeClient {
|
|
75
|
+
private readonly options;
|
|
76
|
+
private socket?;
|
|
77
|
+
/** Bumped for every socket, so events from a socket already replaced are ignored. */
|
|
78
|
+
private generation;
|
|
79
|
+
private attempts;
|
|
80
|
+
private reconnectTimer?;
|
|
81
|
+
private stopped;
|
|
82
|
+
private ready;
|
|
83
|
+
constructor(options: SocketModeClientOptions);
|
|
84
|
+
/** Whether the current connection has received `hello`. */
|
|
85
|
+
get isReady(): boolean;
|
|
86
|
+
/**
|
|
87
|
+
* Opens the first connection. Resolves once the socket is open, not on `hello`.
|
|
88
|
+
*
|
|
89
|
+
* @throws Whatever `openUrl` or `connect` throws for that first attempt, so a bad token or a
|
|
90
|
+
* refused host surfaces to the caller. Later reconnects retry with backoff instead.
|
|
91
|
+
*/
|
|
92
|
+
start(): Promise<void>;
|
|
93
|
+
/** Closes the connection and cancels any pending reconnect. It never reconnects after this. */
|
|
94
|
+
stop(): void;
|
|
95
|
+
/** Opens a fresh URL and socket, and makes it current. Any previous socket is closed after. */
|
|
96
|
+
private open;
|
|
97
|
+
private onFrame;
|
|
98
|
+
private dispatch;
|
|
99
|
+
private onDisconnect;
|
|
100
|
+
private onClose;
|
|
101
|
+
private scheduleReconnect;
|
|
102
|
+
private fail;
|
|
103
|
+
}
|
|
104
|
+
//# sourceMappingURL=slack.socket.mode.client.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"slack.socket.mode.client.d.ts","sourceRoot":"","sources":["../../src/client/slack.socket.mode.client.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,0BAA0B,CAAC;AAClD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,6BAA6B,CAAC;AACvE,OAAO,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAC/C,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AACpE,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,iCAAiC,CAAC;AAC/E,OAAO,KAAK,EAAE,aAAa,EAAc,MAAM,oBAAoB,CAAC;AAEpE,oEAAoE;AACpE,eAAO,MAAM,sCAAsC,OAAQ,CAAC;AAC5D,8DAA8D;AAC9D,eAAO,MAAM,kCAAkC,QAAS,CAAC;AAEzD,mFAAmF;AACnF,MAAM,MAAM,sBAAsB,GAAG;IACnC,wEAAwE;IACxE,UAAU,EAAE,MAAM,CAAC;IACnB,4EAA4E;IAC5E,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,+CAA+C;IAC/C,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB,CAAC;AAEF;;;;;GAKG;AACH,MAAM,MAAM,kBAAkB,GAAG;IAC/B,iDAAiD;IACjD,WAAW,CAAC,EAAE,CAAC,OAAO,EAAE,kBAAkB,EAAE,IAAI,EAAE,sBAAsB,KAAK,OAAO,CAAC;IACrF,uBAAuB;IACvB,cAAc,CAAC,EAAE,CAAC,OAAO,EAAE,mBAAmB,EAAE,IAAI,EAAE,sBAAsB,KAAK,OAAO,CAAC;IACzF,kFAAkF;IAClF,aAAa,CAAC,EAAE,CAAC,OAAO,EAAE,uBAAuB,EAAE,IAAI,EAAE,sBAAsB,KAAK,OAAO,CAAC;CAC7F,CAAC;AAEF,4CAA4C;AAC5C,MAAM,MAAM,uBAAuB,GAAG;IACpC,mGAAmG;IACnG,OAAO,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,CAAC;IAC/B,6DAA6D;IAC7D,OAAO,EAAE,aAAa,CAAC;IACvB,oCAAoC;IACpC,QAAQ,EAAE,kBAAkB,CAAC;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,4FAA4F;IAC5F,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,UAAU,KAAK,IAAI,CAAC;IACtC,uFAAuF;IACvF,OAAO,CAAC,EAAE;QAAE,SAAS,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;CAClD,CAAC;AAWF;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,qBAAa,gBAAgB;IASf,OAAO,CAAC,QAAQ,CAAC,OAAO;IARpC,OAAO,CAAC,MAAM,CAAC,CAAa;IAC5B,qFAAqF;IACrF,OAAO,CAAC,UAAU,CAAK;IACvB,OAAO,CAAC,QAAQ,CAAK;IACrB,OAAO,CAAC,cAAc,CAAC,CAAgC;IACvD,OAAO,CAAC,OAAO,CAAQ;IACvB,OAAO,CAAC,KAAK,CAAS;gBAEO,OAAO,EAAE,uBAAuB;IAE7D,2DAA2D;IAC3D,IAAI,OAAO,IAAI,OAAO,CAErB;IAED;;;;;OAKG;IACG,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAY5B,+FAA+F;IAC/F,IAAI,IAAI,IAAI;IAWZ,+FAA+F;YACjF,IAAI;IAyBlB,OAAO,CAAC,OAAO;IAqCf,OAAO,CAAC,QAAQ;IAkBhB,OAAO,CAAC,YAAY;IAYpB,OAAO,CAAC,OAAO;IAQf,OAAO,CAAC,iBAAiB;IAezB,OAAO,CAAC,IAAI;CAKb"}
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import {
|
|
2
|
+
IsSlackError,
|
|
3
|
+
SlackError
|
|
4
|
+
} from "./chunk-22F5RFRD.js";
|
|
1
5
|
import {
|
|
2
6
|
__name
|
|
3
7
|
} from "./chunk-7QVYU63E.js";
|
|
@@ -20,21 +24,17 @@ SlackConfig = _ts_decorate([
|
|
|
20
24
|
Injectable()
|
|
21
25
|
], SlackConfig);
|
|
22
26
|
|
|
23
|
-
// src/slack.error.ts
|
|
24
|
-
import { ServerkitError } from "@maroonedsoftware/errors";
|
|
25
|
-
var SlackError = class extends ServerkitError {
|
|
26
|
-
static {
|
|
27
|
-
__name(this, "SlackError");
|
|
28
|
-
}
|
|
29
|
-
};
|
|
30
|
-
var IsSlackError = /* @__PURE__ */ __name((error) => error instanceof SlackError, "IsSlackError");
|
|
31
|
-
|
|
32
27
|
// src/slack.signature.ts
|
|
33
28
|
import { createHmac, timingSafeEqual } from "crypto";
|
|
34
29
|
import { DateTime } from "luxon";
|
|
35
30
|
var SLACK_SIGNATURE_DEFAULT_MAX_AGE_SECONDS = 300;
|
|
36
31
|
var verifySlackSignature = /* @__PURE__ */ __name((input) => {
|
|
37
32
|
const { signingSecret, rawBody, timestamp, signature, maxAgeSeconds = SLACK_SIGNATURE_DEFAULT_MAX_AGE_SECONDS, now = Math.floor(DateTime.now().toSeconds()) } = input;
|
|
33
|
+
if (!signingSecret) {
|
|
34
|
+
throw new SlackError("Slack signature verification needs SlackConfig.signingSecret, which is not set").withInternalDetails({
|
|
35
|
+
reason: "missing_signing_secret"
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
38
|
if (!timestamp) {
|
|
39
39
|
throw new SlackError("Slack request missing X-Slack-Request-Timestamp header").withInternalDetails({
|
|
40
40
|
reason: "missing_timestamp"
|
|
@@ -368,12 +368,24 @@ var SlackClient = class {
|
|
|
368
368
|
logger;
|
|
369
369
|
/** Underlying `@slack/web-api` client. */
|
|
370
370
|
web;
|
|
371
|
+
/** Web API client authenticated with the app token, built on first use by {@link openSocketModeUrl}. */
|
|
372
|
+
appWeb;
|
|
371
373
|
constructor(config, logger) {
|
|
372
374
|
this.config = config;
|
|
373
375
|
this.logger = logger;
|
|
374
|
-
this.web = new WebClient(config.botToken,
|
|
375
|
-
|
|
376
|
-
|
|
376
|
+
this.web = new WebClient(config.botToken, this.webClientOptions());
|
|
377
|
+
}
|
|
378
|
+
/** Options shared by every `WebClient` this class builds, passing only what the config sets. */
|
|
379
|
+
webClientOptions() {
|
|
380
|
+
return {
|
|
381
|
+
logger: adaptLogger(this.logger),
|
|
382
|
+
...this.config.fetch ? {
|
|
383
|
+
fetch: this.config.fetch
|
|
384
|
+
} : {},
|
|
385
|
+
...this.config.apiBaseUrl ? {
|
|
386
|
+
slackApiUrl: this.config.apiBaseUrl
|
|
387
|
+
} : {}
|
|
388
|
+
};
|
|
377
389
|
}
|
|
378
390
|
/** Posts a message via `chat.postMessage`. */
|
|
379
391
|
postMessage(args) {
|
|
@@ -403,17 +415,31 @@ var SlackClient = class {
|
|
|
403
415
|
if (!target) {
|
|
404
416
|
throw new SlackError("SlackClient.postWebhook called but no incomingWebhookUrl is configured and no url was provided");
|
|
405
417
|
}
|
|
406
|
-
const
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
418
|
+
const safeUrl = redactSlackUrl(target);
|
|
419
|
+
const fetcher = this.config.fetch ?? fetch;
|
|
420
|
+
let response;
|
|
421
|
+
try {
|
|
422
|
+
response = await fetcher(target, {
|
|
423
|
+
method: "POST",
|
|
424
|
+
headers: {
|
|
425
|
+
"content-type": "application/json"
|
|
426
|
+
},
|
|
427
|
+
body: JSON.stringify(payload),
|
|
428
|
+
signal: AbortSignal.timeout(this.config.requestTimeoutMs ?? SLACK_DEFAULT_REQUEST_TIMEOUT_MS)
|
|
429
|
+
});
|
|
430
|
+
} catch (error) {
|
|
431
|
+
const reason = (error instanceof Error ? error.message : String(error)).split(target).join(safeUrl);
|
|
432
|
+
this.logger.warn("Slack webhook POST did not reach Slack", {
|
|
433
|
+
url: safeUrl,
|
|
434
|
+
reason
|
|
435
|
+
});
|
|
436
|
+
throw new SlackError("Slack webhook POST did not reach Slack").withInternalDetails({
|
|
437
|
+
url: safeUrl,
|
|
438
|
+
reason
|
|
439
|
+
});
|
|
440
|
+
}
|
|
414
441
|
if (!response.ok) {
|
|
415
442
|
const body = await response.text().catch(() => "");
|
|
416
|
-
const safeUrl = redactSlackUrl(target);
|
|
417
443
|
this.logger.warn("Slack webhook POST returned non-OK status", {
|
|
418
444
|
status: response.status,
|
|
419
445
|
body,
|
|
@@ -426,6 +452,44 @@ var SlackClient = class {
|
|
|
426
452
|
});
|
|
427
453
|
}
|
|
428
454
|
}
|
|
455
|
+
/**
|
|
456
|
+
* Opens a Socket Mode connection slot via `apps.connections.open` and returns the WebSocket URL
|
|
457
|
+
* to connect to. Authenticates with `appToken`, not the bot token, over the same `fetch` and base
|
|
458
|
+
* URL as every other call. Each URL is single-use, so call this again for every reconnect.
|
|
459
|
+
*
|
|
460
|
+
* @throws {@link SlackError} if `appToken` is not configured or Slack does not hand back a URL.
|
|
461
|
+
*/
|
|
462
|
+
async openSocketModeUrl() {
|
|
463
|
+
const appToken = this.config.appToken;
|
|
464
|
+
if (!appToken) {
|
|
465
|
+
throw new SlackError("SlackClient.openSocketModeUrl called but no appToken (xapp-...) is configured");
|
|
466
|
+
}
|
|
467
|
+
this.appWeb ??= new WebClient(appToken, this.webClientOptions());
|
|
468
|
+
let result;
|
|
469
|
+
try {
|
|
470
|
+
result = await this.appWeb.apps.connections.open();
|
|
471
|
+
} catch (error) {
|
|
472
|
+
const code = error.data?.error;
|
|
473
|
+
const reason = error instanceof Error ? error.message.split(appToken).join("<token>") : String(error);
|
|
474
|
+
this.logger.warn("Slack apps.connections.open failed", {
|
|
475
|
+
error: code,
|
|
476
|
+
reason
|
|
477
|
+
});
|
|
478
|
+
throw new SlackError("Slack apps.connections.open failed").withInternalDetails({
|
|
479
|
+
error: code,
|
|
480
|
+
reason
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
if (!result.ok || !result.url) {
|
|
484
|
+
this.logger.warn("Slack apps.connections.open returned no URL", {
|
|
485
|
+
error: result.error
|
|
486
|
+
});
|
|
487
|
+
throw new SlackError("Slack apps.connections.open returned no URL").withInternalDetails({
|
|
488
|
+
error: result.error
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
return result.url;
|
|
492
|
+
}
|
|
429
493
|
};
|
|
430
494
|
SlackClient = _ts_decorate4([
|
|
431
495
|
Injectable4(),
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/slack.config.ts","../src/slack.error.ts","../src/slack.signature.ts","../src/slack.signature.policy.ts","../src/slack.event.handler.ts","../src/slack.interaction.handler.ts","../src/slack.dispatcher.ts","../src/client/slack.client.ts","../src/client/slack.logger.adapter.ts"],"sourcesContent":["/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */\nimport { Injectable } from 'injectkit';\n\n/**\n * Configuration for the Slack package. Declared as an abstract `@Injectable()`\n * class so it doubles as a DI token (mirrors the `Logger` pattern in\n * `@maroonedsoftware/logger`).\n *\n * Consumers register a concrete value at bootstrap, typically resolved from\n * `AppConfig`:\n *\n * ```ts\n * const slackConfig = appConfig.getAs<SlackConfig>('slack');\n * registry.register(SlackConfig).useValue(slackConfig);\n * ```\n *\n * Services in this package take `SlackConfig` directly in their constructor.\n */\nexport interface SlackConfig {\n /** Bot user OAuth token (`xoxb-...`). Required for Web API calls. */\n botToken: string;\n /** App-level signing secret used to verify request signatures. */\n signingSecret: string;\n /** Optional incoming webhook URL used as the default target for `SlackClient.postWebhook`. */\n incomingWebhookUrl?: string;\n /**\n * Maximum age (in seconds) for request timestamps before signature\n * verification rejects them as replays. Defaults to `300` (5 minutes).\n */\n signatureMaxAgeSeconds?: number;\n /**\n * Per-request timeout (in milliseconds) for outbound `SlackClient.postWebhook`\n * calls. Defaults to\n * {@link import('./client/slack.client.js').SLACK_DEFAULT_REQUEST_TIMEOUT_MS} (10s).\n */\n requestTimeoutMs?: number;\n}\n\n@Injectable()\nexport abstract class SlackConfig implements SlackConfig {}\n","import { ServerkitError } from '@maroonedsoftware/errors';\n\n/**\n * Domain error raised by the Slack package for non-HTTP failures (e.g.\n * incoming-webhook POST failed, unknown handler dispatch).\n *\n * Extends {@link ServerkitError} so `errorMiddleware` renders a 500 with\n * `{ message, details }` if one of these escapes a route handler. Inside\n * route handlers, throw `httpError(...)` directly for status-coded responses.\n */\nexport class SlackError extends ServerkitError {}\n\n/**\n * Type guard for {@link SlackError}. Narrows `unknown` to `SlackError` so\n * `details`, `internalDetails`, and the chainable setters are accessible\n * without further checks. Returns `true` for any subclass.\n */\nexport const IsSlackError = (error: unknown): error is SlackError => error instanceof SlackError;\n","import { createHmac, timingSafeEqual } from 'node:crypto';\nimport { DateTime } from 'luxon';\nimport { SlackError } from './slack.error.js';\n\n/** Default replay-protection window in seconds (5 minutes — matches Slack's recommendation). */\nexport const SLACK_SIGNATURE_DEFAULT_MAX_AGE_SECONDS = 300;\n\n/**\n * Reason codes attached to {@link SlackError.internalDetails} when verification\n * fails. Useful for callers that want to log structured reasons without\n * pattern-matching on error messages.\n */\nexport type SlackSignatureFailureReason = 'missing_timestamp' | 'invalid_timestamp' | 'stale_timestamp' | 'missing_signature' | 'invalid_signature';\n\n/**\n * Inputs to {@link verifySlackSignature}. All values are taken verbatim from\n * the request — the helper does no header lookups or body reads of its own.\n */\nexport type VerifySlackSignatureInput = {\n /** App signing secret (`SlackConfig.signingSecret`). */\n signingSecret: string;\n /** Raw, unparsed request body — exactly as Slack sent it. */\n rawBody: string;\n /** Value of the `X-Slack-Request-Timestamp` header. */\n timestamp: string | undefined;\n /** Value of the `X-Slack-Signature` header (e.g. `\"v0=abc123…\"`). */\n signature: string | undefined;\n /**\n * Maximum age in seconds before the request is rejected as a replay.\n * Defaults to {@link SLACK_SIGNATURE_DEFAULT_MAX_AGE_SECONDS}.\n */\n maxAgeSeconds?: number;\n /**\n * Override for the current Unix time in seconds. Mostly useful for tests;\n * defaults to `Math.floor(DateTime.now().toSeconds())`.\n */\n now?: number;\n};\n\n/**\n * Verifies a Slack request signature against the app signing secret.\n *\n * Implements Slack's v0 scheme:\n * 1. Reject the request if `X-Slack-Request-Timestamp` is missing, non-numeric,\n * or older than `maxAgeSeconds` (replay protection).\n * 2. Compute `v0=` + `HMAC-SHA256(signingSecret, \"v0:{timestamp}:{rawBody}\")`\n * as hex.\n * 3. Compare against the provided `X-Slack-Signature` value using a\n * constant-time compare.\n *\n * Pure: no request/context coupling. The caller extracts the headers and raw\n * body from whatever transport it's using and passes them in.\n *\n * @throws {@link SlackError} on any failure. The error's `internalDetails.reason`\n * is one of {@link SlackSignatureFailureReason}; map to HTTP 401 at the route boundary.\n *\n * @example\n * ```ts\n * try {\n * verifySlackSignature({\n * signingSecret: config.signingSecret,\n * rawBody,\n * timestamp: req.headers['x-slack-request-timestamp'],\n * signature: req.headers['x-slack-signature'],\n * });\n * } catch (err) {\n * throw httpError(401).withCause(err);\n * }\n * ```\n */\nexport const verifySlackSignature = (input: VerifySlackSignatureInput): void => {\n const {\n signingSecret,\n rawBody,\n timestamp,\n signature,\n maxAgeSeconds = SLACK_SIGNATURE_DEFAULT_MAX_AGE_SECONDS,\n now = Math.floor(DateTime.now().toSeconds()),\n } = input;\n\n if (!timestamp) {\n throw new SlackError('Slack request missing X-Slack-Request-Timestamp header').withInternalDetails({\n reason: 'missing_timestamp' satisfies SlackSignatureFailureReason,\n });\n }\n\n const ts = Number(timestamp);\n if (!Number.isFinite(ts) || !Number.isInteger(ts)) {\n throw new SlackError('Slack request timestamp is not a valid integer').withInternalDetails({\n reason: 'invalid_timestamp' satisfies SlackSignatureFailureReason,\n timestamp,\n });\n }\n\n if (Math.abs(now - ts) > maxAgeSeconds) {\n throw new SlackError('Slack request timestamp is outside the allowed window').withInternalDetails({\n reason: 'stale_timestamp' satisfies SlackSignatureFailureReason,\n timestamp: ts,\n now,\n maxAgeSeconds,\n });\n }\n\n if (!signature) {\n throw new SlackError('Slack request missing X-Slack-Signature header').withInternalDetails({\n reason: 'missing_signature' satisfies SlackSignatureFailureReason,\n });\n }\n\n // Sign with the raw header value verbatim (not the parsed `ts`): Slack computes\n // its signature over the exact `X-Slack-Request-Timestamp` string it sent, so a\n // non-canonical-but-numeric header (e.g. leading zeros) must round-trip as-is.\n const expected = `v0=${createHmac('sha256', signingSecret).update(`v0:${timestamp}:${rawBody}`).digest('hex')}`;\n const expectedBuf = Buffer.from(expected, 'utf8');\n const providedBuf = Buffer.from(signature, 'utf8');\n\n // timingSafeEqual throws on length mismatch — short-circuit so the caller\n // gets a uniform \"invalid_signature\" error instead of a crypto exception.\n if (expectedBuf.length !== providedBuf.length || !timingSafeEqual(expectedBuf, providedBuf)) {\n throw new SlackError('Slack request signature does not match').withInternalDetails({\n reason: 'invalid_signature' satisfies SlackSignatureFailureReason,\n });\n }\n};\n","import { BinaryLike } from 'node:crypto';\nimport { Injectable } from 'injectkit';\nimport { Policy, PolicyEnvelope, PolicyResult } from '@maroonedsoftware/policies';\nimport { SlackConfig } from './slack.config.js';\nimport { IsSlackError } from './slack.error.js';\nimport { verifySlackSignature, type SlackSignatureFailureReason } from './slack.signature.js';\n\n/**\n * Policy name under which {@link SlackSignaturePolicy} is registered. Use as the\n * key when wiring your `PolicyRegistryMap`, and pass to `PolicyService.check`.\n */\nexport const SLACK_SIGNATURE_POLICY = 'slack.signature.valid' as const;\n\n/** Header carrying the request timestamp Slack signs into the HMAC. */\nexport const SLACK_REQUEST_TIMESTAMP_HEADER = 'X-Slack-Request-Timestamp';\n/** Header carrying the `v0=`-prefixed request signature. */\nexport const SLACK_SIGNATURE_HEADER = 'X-Slack-Signature';\n\n/**\n * Configuration the {@link SlackSignaturePolicy} reads. A structural subset of\n * {@link SlackConfig}, so a `SlackConfig` value satisfies it directly — e.g.\n * `requireSignature<SlackSignatureOptions>('slack')` with the Slack config\n * stored under that `AppConfig` key.\n */\nexport type SlackSignatureOptions = Pick<SlackConfig, 'signingSecret' | 'signatureMaxAgeSeconds'>;\n\n/**\n * Context for {@link SlackSignaturePolicy}: the raw request bytes, a\n * case-insensitive header accessor, and the {@link SlackSignatureOptions}.\n *\n * Structurally compatible with `@maroonedsoftware/koa`'s\n * `SignaturePolicyContext<SlackSignatureOptions>`, so the koa `requireSignature`\n * middleware can drive this policy without the slack package depending on koa —\n * register `SlackSignaturePolicy` under the signature policy name and point the\n * middleware at the `AppConfig` key holding the Slack config.\n */\nexport interface SlackSignaturePolicyContext {\n /** Raw, unparsed request body — exactly as Slack sent it (from `ctx.rawBody`). */\n rawBody: BinaryLike;\n /**\n * Case-insensitive request header accessor (Koa's `ctx.get`); returns `''`\n * when the header is absent.\n */\n getHeader: (name: string) => string;\n /** Slack signing configuration. */\n options: SlackSignatureOptions;\n}\n\n/**\n * Policy form of {@link verifySlackSignature}: verifies a Slack request against\n * the app signing secret using Slack's v0 scheme (HMAC over\n * `v0:{timestamp}:{rawBody}`, `v0=`-prefixed, with timestamp replay\n * protection).\n *\n * Delegates to {@link verifySlackSignature} so the crypto/timestamp logic has a\n * single source of truth, but answers as a {@link PolicyResult} rather than\n * throwing: allows on success, denies on failure with the helper's\n * {@link SlackSignatureFailureReason} as the denial `reason` and its diagnostics\n * (timestamps, window) on `internalDetails` — never the signing secret, never\n * on the wire. The replay window is anchored to `envelope.now` so all policies\n * in an evaluation share one clock.\n *\n * Registered by default under {@link SLACK_SIGNATURE_POLICY}.\n *\n * @example\n * ```ts\n * // Direct evaluation in a route handler:\n * const result = await policyService.check(SLACK_SIGNATURE_POLICY, {\n * rawBody: ctx.rawBody,\n * getHeader: name => ctx.get(name),\n * options: ctx.container.get(SlackConfig),\n * });\n * if (isPolicyResultDenied(result)) throw httpError(401);\n * ```\n */\n@Injectable()\nexport class SlackSignaturePolicy extends Policy<SlackSignaturePolicyContext> {\n async evaluate(context: SlackSignaturePolicyContext, envelope: PolicyEnvelope): Promise<PolicyResult> {\n const { rawBody, getHeader, options } = context;\n\n // Slack signs the raw text body; `ctx.rawBody` may arrive as a Buffer.\n const body = typeof rawBody === 'string' ? rawBody : Buffer.from(rawBody as Uint8Array).toString('utf8');\n\n try {\n verifySlackSignature({\n signingSecret: options.signingSecret,\n rawBody: body,\n timestamp: getHeader(SLACK_REQUEST_TIMESTAMP_HEADER),\n signature: getHeader(SLACK_SIGNATURE_HEADER),\n maxAgeSeconds: options.signatureMaxAgeSeconds,\n now: Math.floor(envelope.now.toSeconds()),\n });\n return this.allow();\n } catch (error) {\n if (!IsSlackError(error)) throw error;\n\n const internalDetails = error.internalDetails ?? {};\n const reason =\n typeof internalDetails.reason === 'string' ? internalDetails.reason : ('invalid_signature' satisfies SlackSignatureFailureReason);\n return this.deny(reason, undefined, { message: error.message, ...internalDetails });\n }\n }\n}\n","/**\n * Metadata accompanying every dispatched Slack event. Includes the wrapping\n * envelope fields (team/event IDs) plus the raw `event_callback` payload for\n * handlers that need fields the typed `event` object doesn't expose.\n */\nexport type SlackEventContext = {\n /** Slack workspace / team ID from the envelope. */\n teamId: string;\n /** Unique event ID Slack assigns to each delivery. */\n eventId: string;\n /** Unix timestamp the event was generated. */\n eventTime: number;\n /** Original `event_callback` envelope, untouched. */\n envelope: SlackEventCallback;\n};\n\n/**\n * Slack `event_callback` envelope. The shape is documented at\n * https://api.slack.com/types/event. We type the wrapper but leave the inner\n * `event` as `Record<string, unknown>` because the union of all Slack event\n * payloads is large and consumers typically narrow per handler.\n */\nexport type SlackEventCallback = {\n type: 'event_callback';\n team_id: string;\n api_app_id: string;\n event: { type: string } & Record<string, unknown>;\n event_id: string;\n event_time: number;\n authorizations?: unknown[];\n is_ext_shared_channel?: boolean;\n event_context?: string;\n [key: string]: unknown;\n};\n\n/**\n * Derive a stable, collision-free idempotency key for a Slack event delivery.\n *\n * Slack redelivers an `event_callback` (with an `X-Slack-Retry-Num` header) when\n * the initial ack is slow or non-2xx. The assigned `event_id` is stable across\n * those redeliveries, so it keys de-duplication. We scope it by `team_id` where\n * present so ids from different workspaces can never collide.\n *\n * @param envelope - The `event_callback` envelope (only `event_id` / `team_id` are read).\n * @returns `slack:event:{team_id}:{event_id}`, or `slack:event:{event_id}` when no team id.\n */\nexport function slackEventIdempotencyKey(envelope: Pick<SlackEventCallback, 'event_id' | 'team_id'>): string {\n return envelope.team_id ? `slack:event:${envelope.team_id}:${envelope.event_id}` : `slack:event:${envelope.event_id}`;\n}\n\n/**\n * Handler for a single Slack event type (e.g. `app_mention`, `message`,\n * `reaction_added`). Registered in {@link SlackEventHandlerMap}.\n *\n * Handlers should ack quickly — Slack retries any event that doesn't get a\n * 2xx response within ~3 seconds. For slow work, enqueue a job\n * (`@maroonedsoftware/jobbroker`) inside `handle` and return immediately.\n */\nexport interface SlackEventHandler<TEvent extends { type: string } & Record<string, unknown> = { type: string } & Record<string, unknown>> {\n handle(event: TEvent, context: SlackEventContext): Promise<void>;\n}\n","/**\n * The supported interactive payload types Slack POSTs to the interactivity\n * endpoint. Each maps to a different identifier shape (see\n * {@link interactionRouteKey}).\n */\nexport type SlackInteractionType = 'block_actions' | 'view_submission' | 'view_closed' | 'shortcut' | 'message_action' | string;\n\n/**\n * Loose typing for the interactive payload; consumers narrow per handler.\n * Slack's payloads vary by type, but every variant has a `type` field plus\n * one of: `actions[].action_id`, `view.callback_id`, or top-level `callback_id`.\n */\nexport type SlackInteractionPayload = {\n type: SlackInteractionType;\n team?: { id: string; domain?: string };\n user?: { id: string; name?: string };\n trigger_id?: string;\n response_url?: string;\n actions?: Array<{ action_id: string; block_id?: string; value?: string; [key: string]: unknown }>;\n view?: { id: string; callback_id: string; [key: string]: unknown };\n callback_id?: string;\n [key: string]: unknown;\n};\n\n/**\n * Optional response Slack accepts for `view_submission` / `view_closed`\n * payloads (e.g. to display validation errors or update a modal).\n */\nexport type SlackInteractionResponse = {\n response_action?: 'errors' | 'update' | 'push' | 'clear';\n errors?: Record<string, string>;\n view?: unknown;\n [key: string]: unknown;\n};\n\n/**\n * Handler for one interactive payload, keyed in {@link SlackInteractionHandlerMap}\n * by `${type}:${identifier}` — see {@link interactionRouteKey}.\n */\nexport interface SlackInteractionHandler {\n handle(payload: SlackInteractionPayload): Promise<SlackInteractionResponse | void>;\n}\n\n/**\n * Computes the routing key used by {@link SlackDispatcher.dispatchInteraction}\n * to look a handler up in {@link SlackInteractionHandlerMap}.\n *\n * - `block_actions` → `block_actions:<first action.action_id>`\n * - `view_submission` / `view_closed` → `<type>:<view.callback_id>`\n * - `shortcut` / `message_action` → `<type>:<callback_id>`\n * - any other type with a `callback_id` → `<type>:<callback_id>`\n *\n * @returns The routing key, or `undefined` if the payload doesn't carry an\n * identifier we can route on (e.g. a `block_actions` payload with no actions).\n */\nexport const interactionRouteKey = (payload: SlackInteractionPayload): string | undefined => {\n switch (payload.type) {\n case 'block_actions': {\n const id = payload.actions?.[0]?.action_id;\n return id ? `block_actions:${id}` : undefined;\n }\n case 'view_submission':\n case 'view_closed': {\n const id = payload.view?.callback_id;\n return id ? `${payload.type}:${id}` : undefined;\n }\n case 'shortcut':\n case 'message_action': {\n return payload.callback_id ? `${payload.type}:${payload.callback_id}` : undefined;\n }\n default: {\n return payload.callback_id ? `${payload.type}:${payload.callback_id}` : undefined;\n }\n }\n};\n","import { Injectable } from 'injectkit';\nimport { Logger } from '@maroonedsoftware/logger';\nimport type { IdempotencyStore } from '@maroonedsoftware/cache';\nimport { slackEventIdempotencyKey } from './slack.event.handler.js';\nimport type { SlackEventCallback, SlackEventHandler } from './slack.event.handler.js';\nimport type { SlackCommandHandler, SlackCommandPayload, SlackCommandResponse } from './slack.command.handler.js';\nimport {\n interactionRouteKey,\n SlackInteractionHandler,\n type SlackInteractionPayload,\n type SlackInteractionResponse,\n} from './slack.interaction.handler.js';\n\n/**\n * Body shape Slack POSTs to the Events API endpoint. The handshake variant\n * (`url_verification`) is sent once during app configuration; the rest of the\n * traffic is `event_callback` envelopes (or other future top-level types).\n */\nexport type SlackEventsRequest =\n { type: 'url_verification'; challenge: string; token?: string } | SlackEventCallback | { type: string; [key: string]: unknown };\n\n/**\n * Response Slack expects for the `url_verification` handshake. For\n * `event_callback` and unknown event types, the dispatcher returns\n * `undefined` and the caller should ack with HTTP 200.\n */\nexport type SlackEventsResponse = { challenge: string } | undefined;\n\n/**\n * Injectable map of command keyword (e.g. `/deploy`) → {@link SlackCommandHandler}.\n *\n * @example\n * ```ts\n * registry.register(DeployCommandHandler).useClass(DeployCommandHandler).asSingleton();\n *\n * registry\n * .register(SlackCommandHandlerMap)\n * .useMap(SlackCommandHandlerMap)\n * .set('/deploy', DeployCommandHandler);\n * ```\n */\n@Injectable()\nexport class SlackCommandHandlerMap extends Map<string, SlackCommandHandler> {}\n\n/**\n * Injectable map of Slack event type → {@link SlackEventHandler}. Consumers\n * register handlers at bootstrap and place an instance of this map in their\n * DI container; {@link SlackDispatcher.dispatchEvent} resolves it per request.\n *\n * @example\n * ```ts\n * registry.register(MyAppMentionHandler).useClass(MyAppMentionHandler).asSingleton();\n *\n * registry\n * .register(SlackEventHandlerMap)\n * .useMap(SlackEventHandlerMap)\n * .set('app_mention', MyAppMentionHandler);\n * ```\n */\n@Injectable()\nexport class SlackEventHandlerMap extends Map<string, SlackEventHandler> {}\n\n/**\n * Injectable map of interaction routing keys → {@link SlackInteractionHandler}.\n *\n * Keys are produced by `interactionRouteKey(payload)`, which combines the\n * payload `type` with the relevant identifier (`action_id`, `callback_id`,\n * etc.). Register handlers under the same key shape:\n *\n * @example\n * ```ts\n * registry.register(ApproveHandler).useClass(ApproveHandler).asSingleton();\n * registry.register(CreateTicketHandler).useClass(CreateTicketHandler).asSingleton();\n *\n * registry\n * .register(SlackInteractionHandlerMap)\n * .useMap(SlackInteractionHandlerMap)\n * .set('block_actions:approve_button', ApproveHandler)\n * .set('view_submission:create_ticket_modal', CreateTicketHandler);\n * ```\n */\n@Injectable()\nexport class SlackInteractionHandlerMap extends Map<string, SlackInteractionHandler> {}\n\n/**\n * Single entry point for dispatching parsed Slack payloads to registered\n * handlers. Transport-agnostic: the consumer is responsible for receiving\n * the HTTP request, verifying the signature, parsing the body, calling the\n * appropriate `dispatch*` method, and serializing the response.\n *\n * @example Koa route\n * ```ts\n * router.post('/slack/events', async (ctx) => {\n * const raw = await rawBody(ctx.req, { encoding: 'utf8' });\n * verifySlackSignature({\n * signingSecret: ctx.container.get(SlackConfig).signingSecret,\n * rawBody: raw,\n * timestamp: ctx.get('x-slack-request-timestamp'),\n * signature: ctx.get('x-slack-signature'),\n * });\n * const result = await ctx.container.get(SlackDispatcher).dispatchEvent(JSON.parse(raw));\n * if (result) ctx.body = result;\n * else { ctx.status = 200; ctx.body = ''; }\n * });\n * ```\n */\n@Injectable()\nexport class SlackDispatcher {\n constructor(\n private readonly events: SlackEventHandlerMap,\n private readonly commands: SlackCommandHandlerMap,\n private readonly interactions: SlackInteractionHandlerMap,\n private readonly logger: Logger,\n ) {}\n\n /**\n * Dispatch a parsed Events API body.\n *\n * - Returns `{ challenge }` for `url_verification` — the caller serializes\n * it as the response body. This handshake is NEVER de-duplicated: it must\n * always echo the challenge.\n * - For `event_callback`, looks up a handler in {@link SlackEventHandlerMap}\n * keyed by `event.type` and invokes it. Returns `undefined`. Slack retries\n * any non-2xx so unknown event types are logged at debug and acked.\n * - For any other top-level type, logs and returns `undefined`.\n *\n * Pass `options.idempotency` to de-duplicate `event_callback` deliveries: Slack\n * redelivers events (with an `X-Slack-Retry-Num` header) on a slow/failed ack,\n * so wrapping the handler in an {@link IdempotencyStore} keyed by\n * {@link slackEventIdempotencyKey} runs it at most once per `event_id`. A\n * `duplicate`/`dropped` outcome skips the handler and acks (returns `undefined`).\n * When `options.idempotency` is omitted, behaviour is unchanged.\n */\n async dispatchEvent(body: SlackEventsRequest, options?: { idempotency?: IdempotencyStore }): Promise<SlackEventsResponse> {\n if (body.type === 'url_verification') {\n return { challenge: (body as { challenge: string }).challenge };\n }\n\n if (body.type === 'event_callback') {\n const envelope = body as SlackEventCallback;\n const handleEvent = async (): Promise<void> => {\n const handler = this.events.get(envelope.event.type);\n if (handler) {\n await handler.handle(envelope.event, {\n teamId: envelope.team_id,\n eventId: envelope.event_id,\n eventTime: envelope.event_time,\n envelope,\n });\n } else {\n this.logger.debug('No Slack event handler registered for event type', { type: envelope.event.type });\n }\n };\n\n if (options?.idempotency) {\n const key = slackEventIdempotencyKey(envelope);\n const outcome = await options.idempotency.deduplicate(key, handleEvent);\n if (outcome.status === 'dropped') {\n this.logger.warn('Slack event dead-lettered after repeated failures', { key, attempts: outcome.attempts });\n }\n return undefined;\n }\n\n await handleEvent();\n return undefined;\n }\n\n this.logger.debug('Unhandled Slack events payload type', { type: body.type });\n return undefined;\n }\n\n /**\n * Dispatch a parsed slash-command payload.\n *\n * Looks up a handler in {@link SlackCommandHandlerMap} keyed by\n * `payload.command` (e.g. `/deploy`). If the handler returns a response,\n * the caller serializes it as JSON; otherwise the caller acks with `200 ''`\n * and the handler is expected to follow up via `payload.response_url`.\n */\n async dispatchCommand(payload: SlackCommandPayload): Promise<SlackCommandResponse | void> {\n const handler = this.commands.get(payload.command);\n if (!handler) {\n this.logger.debug('No Slack command handler registered', { command: payload.command });\n return undefined;\n }\n return await handler.handle(payload);\n }\n\n /**\n * Dispatch a parsed interactive payload (block actions, view submission,\n * shortcut, etc.). Computes a routing key via {@link interactionRouteKey}\n * and looks it up in {@link SlackInteractionHandlerMap}.\n */\n async dispatchInteraction(payload: SlackInteractionPayload): Promise<SlackInteractionResponse | void> {\n const key = interactionRouteKey(payload);\n if (!key) {\n this.logger.debug('Slack interaction payload missing routable identifier', { type: payload.type });\n return undefined;\n }\n const handler = this.interactions.get(key);\n if (!handler) {\n this.logger.debug('No Slack interaction handler registered', { key });\n return undefined;\n }\n return await handler.handle(payload);\n }\n}\n","import { Injectable } from 'injectkit';\nimport { WebClient } from '@slack/web-api';\nimport type {\n ChatPostMessageArguments,\n ChatPostMessageResponse,\n ChatUpdateArguments,\n ChatUpdateResponse,\n ChatDeleteArguments,\n ChatDeleteResponse,\n ViewsOpenArguments,\n ViewsOpenResponse,\n} from '@slack/web-api';\nimport { Logger } from '@maroonedsoftware/logger';\nimport { SlackConfig } from '../slack.config.js';\nimport { SlackError } from '../slack.error.js';\nimport { adaptLogger } from './slack.logger.adapter.js';\n\n/** Default per-request timeout (ms) applied to outbound `postWebhook` calls. */\nexport const SLACK_DEFAULT_REQUEST_TIMEOUT_MS = 10_000;\n\n/**\n * Redacts a Slack webhook / `response_url` so it is safe to log. The final path\n * segment is the secret token (and the query string can carry secrets too), so\n * both are stripped, leaving only the host and path prefix.\n */\nexport const redactSlackUrl = (raw: string): string => {\n try {\n const url = new URL(raw);\n const segments = url.pathname.split('/').filter(Boolean);\n if (segments.length > 0) segments[segments.length - 1] = '***';\n return `${url.origin}/${segments.join('/')}`;\n } catch {\n return '***';\n }\n};\n\n/**\n * Payload for an incoming-webhook POST. Mirrors the subset of fields Slack's\n * incoming webhooks accept (text, blocks, attachments, response shaping).\n * The body is JSON-stringified verbatim, so any extra fields are preserved.\n */\nexport type IncomingWebhookPayload = {\n text?: string;\n blocks?: unknown[];\n attachments?: unknown[];\n thread_ts?: string;\n response_type?: 'in_channel' | 'ephemeral';\n replace_original?: boolean;\n delete_original?: boolean;\n unfurl_links?: boolean;\n unfurl_media?: boolean;\n [key: string]: unknown;\n};\n\n/**\n * Thin DI-friendly wrapper around `@slack/web-api`'s `WebClient`. Constructed\n * once per request scope (or as a singleton, depending on how the consumer\n * registers it) and exposes typed passthroughs for the most common Web API\n * methods plus a `postWebhook` helper for incoming-webhook URLs and the\n * `response_url` returned by slash commands and interactive payloads.\n *\n * Reach for {@link SlackClient.web} directly for anything else the underlying\n * client supports.\n *\n * @example\n * ```ts\n * await container.get(SlackClient).postMessage({ channel: '#ops', text: 'hello' });\n * await container.get(SlackClient).postWebhook({ text: 'follow-up' }, payload.response_url);\n * ```\n */\n@Injectable()\nexport class SlackClient {\n /** Underlying `@slack/web-api` client. */\n readonly web: WebClient;\n\n constructor(\n private readonly config: SlackConfig,\n private readonly logger: Logger,\n ) {\n this.web = new WebClient(config.botToken, { logger: adaptLogger(logger) });\n }\n\n /** Posts a message via `chat.postMessage`. */\n postMessage(args: ChatPostMessageArguments): Promise<ChatPostMessageResponse> {\n return this.web.chat.postMessage(args);\n }\n\n /** Updates a message via `chat.update`. */\n updateMessage(args: ChatUpdateArguments): Promise<ChatUpdateResponse> {\n return this.web.chat.update(args);\n }\n\n /** Deletes a message via `chat.delete`. */\n deleteMessage(args: ChatDeleteArguments): Promise<ChatDeleteResponse> {\n return this.web.chat.delete(args);\n }\n\n /** Opens a modal view via `views.open`. */\n openView(args: ViewsOpenArguments): Promise<ViewsOpenResponse> {\n return this.web.views.open(args);\n }\n\n /**\n * POSTs a payload to a Slack incoming-webhook-style URL — either the\n * configured `incomingWebhookUrl` or an explicit URL (e.g. the\n * `response_url` from a slash command or interactive payload).\n *\n * @throws {@link SlackError} if no URL is available or the response is non-2xx.\n */\n async postWebhook(payload: IncomingWebhookPayload, url?: string): Promise<void> {\n const target = url ?? this.config.incomingWebhookUrl;\n if (!target) {\n throw new SlackError('SlackClient.postWebhook called but no incomingWebhookUrl is configured and no url was provided');\n }\n const response = await fetch(target, {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify(payload),\n signal: AbortSignal.timeout(this.config.requestTimeoutMs ?? SLACK_DEFAULT_REQUEST_TIMEOUT_MS),\n });\n if (!response.ok) {\n const body = await response.text().catch(() => '');\n // `target` is a response_url / incoming-webhook URL whose last path segment\n // is a secret — redact it before it reaches the log or internalDetails.\n const safeUrl = redactSlackUrl(target);\n this.logger.warn('Slack webhook POST returned non-OK status', { status: response.status, body, url: safeUrl });\n throw new SlackError(`Slack webhook POST returned ${response.status}`).withInternalDetails({ status: response.status, body, url: safeUrl });\n }\n }\n}\n","import type { Logger as SlackLogger, LogLevel } from '@slack/web-api';\nimport { Logger } from '@maroonedsoftware/logger';\n\n/**\n * Adapts a ServerKit {@link Logger} to the `@slack/web-api` {@link SlackLogger}\n * interface so the WebClient can route its diagnostics through the host\n * application's logger.\n *\n * The Slack SDK's logger calls `logger.info(...args)` with a variable number\n * of arguments and no separate \"primary message\"; the adapter forwards them\n * to ServerKit's `(message, ...optionalParams)` shape, with an empty-string\n * primary when no args are passed.\n *\n * `setLevel`, `setName`, and `getLevel` are stored locally — ServerKit\n * loggers do not expose these knobs but the SDK expects them on its logger.\n *\n * @param logger - The ServerKit logger to forward calls to.\n * @param name - Initial value for the SDK logger's name. Defaults to `'slack-web-api'`.\n * @returns A `@slack/web-api`-compatible logger object.\n */\nexport const adaptLogger = (logger: Logger, name = 'slack-web-api'): SlackLogger => {\n const state = { name, level: 'info' as LogLevel };\n const forward =\n (fn: (message: unknown, ...optionalParams: unknown[]) => void) =>\n (...msg: unknown[]) => {\n const [first, ...rest] = msg;\n fn(first ?? '', ...rest);\n };\n return {\n debug: forward(logger.debug.bind(logger)),\n info: forward(logger.info.bind(logger)),\n warn: forward(logger.warn.bind(logger)),\n error: forward(logger.error.bind(logger)),\n setLevel: (level: LogLevel) => {\n state.level = level;\n },\n getLevel: () => state.level,\n setName: (n: string) => {\n state.name = n;\n },\n };\n};\n"],"mappings":"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","names":["Injectable","SlackConfig","ServerkitError","SlackError","ServerkitError","IsSlackError","error","createHmac","timingSafeEqual","DateTime","SLACK_SIGNATURE_DEFAULT_MAX_AGE_SECONDS","verifySlackSignature","input","signingSecret","rawBody","timestamp","signature","maxAgeSeconds","now","Math","floor","DateTime","toSeconds","SlackError","withInternalDetails","reason","ts","Number","isFinite","isInteger","abs","expected","createHmac","update","digest","expectedBuf","Buffer","from","providedBuf","length","timingSafeEqual","Injectable","Policy","SLACK_SIGNATURE_POLICY","SLACK_REQUEST_TIMESTAMP_HEADER","SLACK_SIGNATURE_HEADER","SlackSignaturePolicy","Policy","evaluate","context","envelope","rawBody","getHeader","options","body","Buffer","from","toString","verifySlackSignature","signingSecret","timestamp","signature","maxAgeSeconds","signatureMaxAgeSeconds","now","Math","floor","toSeconds","allow","error","IsSlackError","internalDetails","reason","deny","undefined","message","slackEventIdempotencyKey","envelope","team_id","event_id","interactionRouteKey","payload","type","id","actions","action_id","undefined","view","callback_id","Injectable","Logger","SlackCommandHandlerMap","Map","SlackEventHandlerMap","SlackInteractionHandlerMap","SlackDispatcher","events","commands","interactions","logger","dispatchEvent","body","options","type","challenge","envelope","handleEvent","handler","get","event","handle","teamId","team_id","eventId","event_id","eventTime","event_time","debug","idempotency","key","slackEventIdempotencyKey","outcome","deduplicate","status","warn","attempts","undefined","dispatchCommand","payload","command","dispatchInteraction","interactionRouteKey","Injectable","WebClient","Logger","adaptLogger","logger","name","state","level","forward","fn","msg","first","rest","debug","bind","info","warn","error","setLevel","getLevel","setName","n","SLACK_DEFAULT_REQUEST_TIMEOUT_MS","redactSlackUrl","raw","url","URL","segments","pathname","split","filter","Boolean","length","origin","join","SlackClient","web","config","logger","WebClient","botToken","adaptLogger","postMessage","args","chat","updateMessage","update","deleteMessage","delete","openView","views","open","postWebhook","payload","target","incomingWebhookUrl","SlackError","response","fetch","method","headers","body","JSON","stringify","signal","AbortSignal","timeout","requestTimeoutMs","ok","text","catch","safeUrl","warn","status","withInternalDetails"]}
|
|
1
|
+
{"version":3,"sources":["../src/slack.config.ts","../src/slack.signature.ts","../src/slack.signature.policy.ts","../src/slack.event.handler.ts","../src/slack.interaction.handler.ts","../src/slack.dispatcher.ts","../src/client/slack.client.ts","../src/client/slack.logger.adapter.ts"],"sourcesContent":["/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */\nimport { Injectable } from 'injectkit';\nimport type { FetchFunction } from '@slack/web-api';\n\n/**\n * Configuration for the Slack package. Declared as an abstract `@Injectable()`\n * class so it doubles as a DI token (mirrors the `Logger` pattern in\n * `@maroonedsoftware/logger`).\n *\n * Consumers register a concrete value at bootstrap, typically resolved from\n * `AppConfig`:\n *\n * ```ts\n * const slackConfig = appConfig.getAs<SlackConfig>('slack');\n * registry.register(SlackConfig).useValue(slackConfig);\n * ```\n *\n * Services in this package take `SlackConfig` directly in their constructor.\n */\nexport interface SlackConfig {\n /** Bot user OAuth token (`xoxb-...`). Required for Web API calls. */\n botToken: string;\n /**\n * App-level signing secret used to verify request signatures. Needed only when Slack calls you\n * over HTTP; a Socket Mode app can leave it unset, and signature verification then fails closed\n * with `missing_signing_secret`.\n */\n signingSecret?: string;\n /**\n * App-level token (`xapp-...`) with the `connections:write` scope. Needed only for Socket Mode,\n * where {@link import('./client/slack.client.js').SlackClient.openSocketModeUrl} trades it for a\n * WebSocket URL.\n */\n appToken?: string;\n /** Optional incoming webhook URL used as the default target for `SlackClient.postWebhook`. */\n incomingWebhookUrl?: string;\n /**\n * Maximum age (in seconds) for request timestamps before signature\n * verification rejects them as replays. Defaults to `300` (5 minutes).\n */\n signatureMaxAgeSeconds?: number;\n /**\n * Per-request timeout (in milliseconds) for outbound `SlackClient.postWebhook`\n * calls. Defaults to\n * {@link import('./client/slack.client.js').SLACK_DEFAULT_REQUEST_TIMEOUT_MS} (10s).\n */\n requestTimeoutMs?: number;\n /**\n * Base URL for Web API calls, forwarded to `@slack/web-api` as `slackApiUrl`. Defaults to the\n * SDK's own (`https://slack.com/api/`).\n */\n apiBaseUrl?: string;\n /**\n * The `fetch` every outbound call goes through: the Web API client, `postWebhook`, and\n * `openSocketModeUrl`. Defaults to the global `fetch`.\n *\n * Set it when the caller owns the transport: a host that routes outbound HTTP through its own\n * allowlist, rate limits or proxy, or a test. The client passes an `AbortSignal` carrying its\n * timeout; an implementation that enforces its own deadline as well may ignore it.\n */\n fetch?: SlackFetch;\n}\n\n/**\n * The `fetch` shape the client needs. It is `@slack/web-api`'s own `FetchFunction`, so one\n * function serves both the Web API client and the webhook POSTs; the global `fetch` satisfies it.\n */\nexport type SlackFetch = FetchFunction;\n\n@Injectable()\nexport abstract class SlackConfig implements SlackConfig {}\n","import { createHmac, timingSafeEqual } from 'node:crypto';\nimport { DateTime } from 'luxon';\nimport { SlackError } from './slack.error.js';\n\n/** Default replay-protection window in seconds (5 minutes — matches Slack's recommendation). */\nexport const SLACK_SIGNATURE_DEFAULT_MAX_AGE_SECONDS = 300;\n\n/**\n * Reason codes attached to {@link SlackError.internalDetails} when verification\n * fails. Useful for callers that want to log structured reasons without\n * pattern-matching on error messages.\n */\nexport type SlackSignatureFailureReason =\n 'missing_signing_secret' | 'missing_timestamp' | 'invalid_timestamp' | 'stale_timestamp' | 'missing_signature' | 'invalid_signature';\n\n/**\n * Inputs to {@link verifySlackSignature}. All values are taken verbatim from\n * the request — the helper does no header lookups or body reads of its own.\n */\nexport type VerifySlackSignatureInput = {\n /**\n * App signing secret (`SlackConfig.signingSecret`). Optional in the config because a Socket\n * Mode app never verifies a request; verification without one fails with\n * `missing_signing_secret` rather than checking against an empty key.\n */\n signingSecret: string | undefined;\n /** Raw, unparsed request body — exactly as Slack sent it. */\n rawBody: string;\n /** Value of the `X-Slack-Request-Timestamp` header. */\n timestamp: string | undefined;\n /** Value of the `X-Slack-Signature` header (e.g. `\"v0=abc123…\"`). */\n signature: string | undefined;\n /**\n * Maximum age in seconds before the request is rejected as a replay.\n * Defaults to {@link SLACK_SIGNATURE_DEFAULT_MAX_AGE_SECONDS}.\n */\n maxAgeSeconds?: number;\n /**\n * Override for the current Unix time in seconds. Mostly useful for tests;\n * defaults to `Math.floor(DateTime.now().toSeconds())`.\n */\n now?: number;\n};\n\n/**\n * Verifies a Slack request signature against the app signing secret.\n *\n * Implements Slack's v0 scheme:\n * 1. Reject the request if `X-Slack-Request-Timestamp` is missing, non-numeric,\n * or older than `maxAgeSeconds` (replay protection).\n * 2. Compute `v0=` + `HMAC-SHA256(signingSecret, \"v0:{timestamp}:{rawBody}\")`\n * as hex.\n * 3. Compare against the provided `X-Slack-Signature` value using a\n * constant-time compare.\n *\n * Pure: no request/context coupling. The caller extracts the headers and raw\n * body from whatever transport it's using and passes them in.\n *\n * @throws {@link SlackError} on any failure. The error's `internalDetails.reason`\n * is one of {@link SlackSignatureFailureReason}; map to HTTP 401 at the route boundary.\n *\n * @example\n * ```ts\n * try {\n * verifySlackSignature({\n * signingSecret: config.signingSecret,\n * rawBody,\n * timestamp: req.headers['x-slack-request-timestamp'],\n * signature: req.headers['x-slack-signature'],\n * });\n * } catch (err) {\n * throw httpError(401).withCause(err);\n * }\n * ```\n */\nexport const verifySlackSignature = (input: VerifySlackSignatureInput): void => {\n const {\n signingSecret,\n rawBody,\n timestamp,\n signature,\n maxAgeSeconds = SLACK_SIGNATURE_DEFAULT_MAX_AGE_SECONDS,\n now = Math.floor(DateTime.now().toSeconds()),\n } = input;\n\n if (!signingSecret) {\n throw new SlackError('Slack signature verification needs SlackConfig.signingSecret, which is not set').withInternalDetails({\n reason: 'missing_signing_secret' satisfies SlackSignatureFailureReason,\n });\n }\n\n if (!timestamp) {\n throw new SlackError('Slack request missing X-Slack-Request-Timestamp header').withInternalDetails({\n reason: 'missing_timestamp' satisfies SlackSignatureFailureReason,\n });\n }\n\n const ts = Number(timestamp);\n if (!Number.isFinite(ts) || !Number.isInteger(ts)) {\n throw new SlackError('Slack request timestamp is not a valid integer').withInternalDetails({\n reason: 'invalid_timestamp' satisfies SlackSignatureFailureReason,\n timestamp,\n });\n }\n\n if (Math.abs(now - ts) > maxAgeSeconds) {\n throw new SlackError('Slack request timestamp is outside the allowed window').withInternalDetails({\n reason: 'stale_timestamp' satisfies SlackSignatureFailureReason,\n timestamp: ts,\n now,\n maxAgeSeconds,\n });\n }\n\n if (!signature) {\n throw new SlackError('Slack request missing X-Slack-Signature header').withInternalDetails({\n reason: 'missing_signature' satisfies SlackSignatureFailureReason,\n });\n }\n\n // Sign with the raw header value verbatim (not the parsed `ts`): Slack computes\n // its signature over the exact `X-Slack-Request-Timestamp` string it sent, so a\n // non-canonical-but-numeric header (e.g. leading zeros) must round-trip as-is.\n const expected = `v0=${createHmac('sha256', signingSecret).update(`v0:${timestamp}:${rawBody}`).digest('hex')}`;\n const expectedBuf = Buffer.from(expected, 'utf8');\n const providedBuf = Buffer.from(signature, 'utf8');\n\n // timingSafeEqual throws on length mismatch — short-circuit so the caller\n // gets a uniform \"invalid_signature\" error instead of a crypto exception.\n if (expectedBuf.length !== providedBuf.length || !timingSafeEqual(expectedBuf, providedBuf)) {\n throw new SlackError('Slack request signature does not match').withInternalDetails({\n reason: 'invalid_signature' satisfies SlackSignatureFailureReason,\n });\n }\n};\n","import { BinaryLike } from 'node:crypto';\nimport { Injectable } from 'injectkit';\nimport { Policy, PolicyEnvelope, PolicyResult } from '@maroonedsoftware/policies';\nimport { SlackConfig } from './slack.config.js';\nimport { IsSlackError } from './slack.error.js';\nimport { verifySlackSignature, type SlackSignatureFailureReason } from './slack.signature.js';\n\n/**\n * Policy name under which {@link SlackSignaturePolicy} is registered. Use as the\n * key when wiring your `PolicyRegistryMap`, and pass to `PolicyService.check`.\n */\nexport const SLACK_SIGNATURE_POLICY = 'slack.signature.valid' as const;\n\n/** Header carrying the request timestamp Slack signs into the HMAC. */\nexport const SLACK_REQUEST_TIMESTAMP_HEADER = 'X-Slack-Request-Timestamp';\n/** Header carrying the `v0=`-prefixed request signature. */\nexport const SLACK_SIGNATURE_HEADER = 'X-Slack-Signature';\n\n/**\n * Configuration the {@link SlackSignaturePolicy} reads. A structural subset of\n * {@link SlackConfig}, so a `SlackConfig` value satisfies it directly — e.g.\n * `requireSignature<SlackSignatureOptions>('slack')` with the Slack config\n * stored under that `AppConfig` key.\n */\nexport type SlackSignatureOptions = Pick<SlackConfig, 'signingSecret' | 'signatureMaxAgeSeconds'>;\n\n/**\n * Context for {@link SlackSignaturePolicy}: the raw request bytes, a\n * case-insensitive header accessor, and the {@link SlackSignatureOptions}.\n *\n * Structurally compatible with `@maroonedsoftware/koa`'s\n * `SignaturePolicyContext<SlackSignatureOptions>`, so the koa `requireSignature`\n * middleware can drive this policy without the slack package depending on koa —\n * register `SlackSignaturePolicy` under the signature policy name and point the\n * middleware at the `AppConfig` key holding the Slack config.\n */\nexport interface SlackSignaturePolicyContext {\n /** Raw, unparsed request body — exactly as Slack sent it (from `ctx.rawBody`). */\n rawBody: BinaryLike;\n /**\n * Case-insensitive request header accessor (Koa's `ctx.get`); returns `''`\n * when the header is absent.\n */\n getHeader: (name: string) => string;\n /** Slack signing configuration. */\n options: SlackSignatureOptions;\n}\n\n/**\n * Policy form of {@link verifySlackSignature}: verifies a Slack request against\n * the app signing secret using Slack's v0 scheme (HMAC over\n * `v0:{timestamp}:{rawBody}`, `v0=`-prefixed, with timestamp replay\n * protection).\n *\n * Delegates to {@link verifySlackSignature} so the crypto/timestamp logic has a\n * single source of truth, but answers as a {@link PolicyResult} rather than\n * throwing: allows on success, denies on failure with the helper's\n * {@link SlackSignatureFailureReason} as the denial `reason` and its diagnostics\n * (timestamps, window) on `internalDetails` — never the signing secret, never\n * on the wire. The replay window is anchored to `envelope.now` so all policies\n * in an evaluation share one clock.\n *\n * Registered by default under {@link SLACK_SIGNATURE_POLICY}.\n *\n * @example\n * ```ts\n * // Direct evaluation in a route handler:\n * const result = await policyService.check(SLACK_SIGNATURE_POLICY, {\n * rawBody: ctx.rawBody,\n * getHeader: name => ctx.get(name),\n * options: ctx.container.get(SlackConfig),\n * });\n * if (isPolicyResultDenied(result)) throw httpError(401);\n * ```\n */\n@Injectable()\nexport class SlackSignaturePolicy extends Policy<SlackSignaturePolicyContext> {\n async evaluate(context: SlackSignaturePolicyContext, envelope: PolicyEnvelope): Promise<PolicyResult> {\n const { rawBody, getHeader, options } = context;\n\n // Slack signs the raw text body; `ctx.rawBody` may arrive as a Buffer.\n const body = typeof rawBody === 'string' ? rawBody : Buffer.from(rawBody as Uint8Array).toString('utf8');\n\n try {\n verifySlackSignature({\n signingSecret: options.signingSecret,\n rawBody: body,\n timestamp: getHeader(SLACK_REQUEST_TIMESTAMP_HEADER),\n signature: getHeader(SLACK_SIGNATURE_HEADER),\n maxAgeSeconds: options.signatureMaxAgeSeconds,\n now: Math.floor(envelope.now.toSeconds()),\n });\n return this.allow();\n } catch (error) {\n if (!IsSlackError(error)) throw error;\n\n const internalDetails = error.internalDetails ?? {};\n const reason =\n typeof internalDetails.reason === 'string' ? internalDetails.reason : ('invalid_signature' satisfies SlackSignatureFailureReason);\n return this.deny(reason, undefined, { message: error.message, ...internalDetails });\n }\n }\n}\n","/**\n * Metadata accompanying every dispatched Slack event. Includes the wrapping\n * envelope fields (team/event IDs) plus the raw `event_callback` payload for\n * handlers that need fields the typed `event` object doesn't expose.\n */\nexport type SlackEventContext = {\n /** Slack workspace / team ID from the envelope. */\n teamId: string;\n /** Unique event ID Slack assigns to each delivery. */\n eventId: string;\n /** Unix timestamp the event was generated. */\n eventTime: number;\n /** Original `event_callback` envelope, untouched. */\n envelope: SlackEventCallback;\n};\n\n/**\n * Slack `event_callback` envelope. The shape is documented at\n * https://api.slack.com/types/event. We type the wrapper but leave the inner\n * `event` as `Record<string, unknown>` because the union of all Slack event\n * payloads is large and consumers typically narrow per handler.\n */\nexport type SlackEventCallback = {\n type: 'event_callback';\n team_id: string;\n api_app_id: string;\n event: { type: string } & Record<string, unknown>;\n event_id: string;\n event_time: number;\n authorizations?: unknown[];\n is_ext_shared_channel?: boolean;\n event_context?: string;\n [key: string]: unknown;\n};\n\n/**\n * Derive a stable, collision-free idempotency key for a Slack event delivery.\n *\n * Slack redelivers an `event_callback` (with an `X-Slack-Retry-Num` header) when\n * the initial ack is slow or non-2xx. The assigned `event_id` is stable across\n * those redeliveries, so it keys de-duplication. We scope it by `team_id` where\n * present so ids from different workspaces can never collide.\n *\n * @param envelope - The `event_callback` envelope (only `event_id` / `team_id` are read).\n * @returns `slack:event:{team_id}:{event_id}`, or `slack:event:{event_id}` when no team id.\n */\nexport function slackEventIdempotencyKey(envelope: Pick<SlackEventCallback, 'event_id' | 'team_id'>): string {\n return envelope.team_id ? `slack:event:${envelope.team_id}:${envelope.event_id}` : `slack:event:${envelope.event_id}`;\n}\n\n/**\n * Handler for a single Slack event type (e.g. `app_mention`, `message`,\n * `reaction_added`). Registered in {@link SlackEventHandlerMap}.\n *\n * Handlers should ack quickly — Slack retries any event that doesn't get a\n * 2xx response within ~3 seconds. For slow work, enqueue a job\n * (`@maroonedsoftware/jobbroker`) inside `handle` and return immediately.\n */\nexport interface SlackEventHandler<TEvent extends { type: string } & Record<string, unknown> = { type: string } & Record<string, unknown>> {\n handle(event: TEvent, context: SlackEventContext): Promise<void>;\n}\n","/**\n * The supported interactive payload types Slack POSTs to the interactivity\n * endpoint. Each maps to a different identifier shape (see\n * {@link interactionRouteKey}).\n */\nexport type SlackInteractionType = 'block_actions' | 'view_submission' | 'view_closed' | 'shortcut' | 'message_action' | string;\n\n/**\n * Loose typing for the interactive payload; consumers narrow per handler.\n * Slack's payloads vary by type, but every variant has a `type` field plus\n * one of: `actions[].action_id`, `view.callback_id`, or top-level `callback_id`.\n */\nexport type SlackInteractionPayload = {\n type: SlackInteractionType;\n team?: { id: string; domain?: string };\n user?: { id: string; name?: string };\n trigger_id?: string;\n response_url?: string;\n actions?: Array<{ action_id: string; block_id?: string; value?: string; [key: string]: unknown }>;\n view?: { id: string; callback_id: string; [key: string]: unknown };\n callback_id?: string;\n [key: string]: unknown;\n};\n\n/**\n * Optional response Slack accepts for `view_submission` / `view_closed`\n * payloads (e.g. to display validation errors or update a modal).\n */\nexport type SlackInteractionResponse = {\n response_action?: 'errors' | 'update' | 'push' | 'clear';\n errors?: Record<string, string>;\n view?: unknown;\n [key: string]: unknown;\n};\n\n/**\n * Handler for one interactive payload, keyed in {@link SlackInteractionHandlerMap}\n * by `${type}:${identifier}` — see {@link interactionRouteKey}.\n */\nexport interface SlackInteractionHandler {\n handle(payload: SlackInteractionPayload): Promise<SlackInteractionResponse | void>;\n}\n\n/**\n * Computes the routing key used by {@link SlackDispatcher.dispatchInteraction}\n * to look a handler up in {@link SlackInteractionHandlerMap}.\n *\n * - `block_actions` → `block_actions:<first action.action_id>`\n * - `view_submission` / `view_closed` → `<type>:<view.callback_id>`\n * - `shortcut` / `message_action` → `<type>:<callback_id>`\n * - any other type with a `callback_id` → `<type>:<callback_id>`\n *\n * @returns The routing key, or `undefined` if the payload doesn't carry an\n * identifier we can route on (e.g. a `block_actions` payload with no actions).\n */\nexport const interactionRouteKey = (payload: SlackInteractionPayload): string | undefined => {\n switch (payload.type) {\n case 'block_actions': {\n const id = payload.actions?.[0]?.action_id;\n return id ? `block_actions:${id}` : undefined;\n }\n case 'view_submission':\n case 'view_closed': {\n const id = payload.view?.callback_id;\n return id ? `${payload.type}:${id}` : undefined;\n }\n case 'shortcut':\n case 'message_action': {\n return payload.callback_id ? `${payload.type}:${payload.callback_id}` : undefined;\n }\n default: {\n return payload.callback_id ? `${payload.type}:${payload.callback_id}` : undefined;\n }\n }\n};\n","import { Injectable } from 'injectkit';\nimport { Logger } from '@maroonedsoftware/logger';\nimport type { IdempotencyStore } from '@maroonedsoftware/cache';\nimport { slackEventIdempotencyKey } from './slack.event.handler.js';\nimport type { SlackEventCallback, SlackEventHandler } from './slack.event.handler.js';\nimport type { SlackCommandHandler, SlackCommandPayload, SlackCommandResponse } from './slack.command.handler.js';\nimport {\n interactionRouteKey,\n SlackInteractionHandler,\n type SlackInteractionPayload,\n type SlackInteractionResponse,\n} from './slack.interaction.handler.js';\n\n/**\n * Body shape Slack POSTs to the Events API endpoint. The handshake variant\n * (`url_verification`) is sent once during app configuration; the rest of the\n * traffic is `event_callback` envelopes (or other future top-level types).\n */\nexport type SlackEventsRequest =\n { type: 'url_verification'; challenge: string; token?: string } | SlackEventCallback | { type: string; [key: string]: unknown };\n\n/**\n * Response Slack expects for the `url_verification` handshake. For\n * `event_callback` and unknown event types, the dispatcher returns\n * `undefined` and the caller should ack with HTTP 200.\n */\nexport type SlackEventsResponse = { challenge: string } | undefined;\n\n/**\n * Injectable map of command keyword (e.g. `/deploy`) → {@link SlackCommandHandler}.\n *\n * @example\n * ```ts\n * registry.register(DeployCommandHandler).useClass(DeployCommandHandler).asSingleton();\n *\n * registry\n * .register(SlackCommandHandlerMap)\n * .useMap(SlackCommandHandlerMap)\n * .set('/deploy', DeployCommandHandler);\n * ```\n */\n@Injectable()\nexport class SlackCommandHandlerMap extends Map<string, SlackCommandHandler> {}\n\n/**\n * Injectable map of Slack event type → {@link SlackEventHandler}. Consumers\n * register handlers at bootstrap and place an instance of this map in their\n * DI container; {@link SlackDispatcher.dispatchEvent} resolves it per request.\n *\n * @example\n * ```ts\n * registry.register(MyAppMentionHandler).useClass(MyAppMentionHandler).asSingleton();\n *\n * registry\n * .register(SlackEventHandlerMap)\n * .useMap(SlackEventHandlerMap)\n * .set('app_mention', MyAppMentionHandler);\n * ```\n */\n@Injectable()\nexport class SlackEventHandlerMap extends Map<string, SlackEventHandler> {}\n\n/**\n * Injectable map of interaction routing keys → {@link SlackInteractionHandler}.\n *\n * Keys are produced by `interactionRouteKey(payload)`, which combines the\n * payload `type` with the relevant identifier (`action_id`, `callback_id`,\n * etc.). Register handlers under the same key shape:\n *\n * @example\n * ```ts\n * registry.register(ApproveHandler).useClass(ApproveHandler).asSingleton();\n * registry.register(CreateTicketHandler).useClass(CreateTicketHandler).asSingleton();\n *\n * registry\n * .register(SlackInteractionHandlerMap)\n * .useMap(SlackInteractionHandlerMap)\n * .set('block_actions:approve_button', ApproveHandler)\n * .set('view_submission:create_ticket_modal', CreateTicketHandler);\n * ```\n */\n@Injectable()\nexport class SlackInteractionHandlerMap extends Map<string, SlackInteractionHandler> {}\n\n/**\n * Single entry point for dispatching parsed Slack payloads to registered\n * handlers. Transport-agnostic: the consumer is responsible for receiving\n * the HTTP request, verifying the signature, parsing the body, calling the\n * appropriate `dispatch*` method, and serializing the response.\n *\n * @example Koa route\n * ```ts\n * router.post('/slack/events', async (ctx) => {\n * const raw = await rawBody(ctx.req, { encoding: 'utf8' });\n * verifySlackSignature({\n * signingSecret: ctx.container.get(SlackConfig).signingSecret,\n * rawBody: raw,\n * timestamp: ctx.get('x-slack-request-timestamp'),\n * signature: ctx.get('x-slack-signature'),\n * });\n * const result = await ctx.container.get(SlackDispatcher).dispatchEvent(JSON.parse(raw));\n * if (result) ctx.body = result;\n * else { ctx.status = 200; ctx.body = ''; }\n * });\n * ```\n */\n@Injectable()\nexport class SlackDispatcher {\n constructor(\n private readonly events: SlackEventHandlerMap,\n private readonly commands: SlackCommandHandlerMap,\n private readonly interactions: SlackInteractionHandlerMap,\n private readonly logger: Logger,\n ) {}\n\n /**\n * Dispatch a parsed Events API body.\n *\n * - Returns `{ challenge }` for `url_verification` — the caller serializes\n * it as the response body. This handshake is NEVER de-duplicated: it must\n * always echo the challenge.\n * - For `event_callback`, looks up a handler in {@link SlackEventHandlerMap}\n * keyed by `event.type` and invokes it. Returns `undefined`. Slack retries\n * any non-2xx so unknown event types are logged at debug and acked.\n * - For any other top-level type, logs and returns `undefined`.\n *\n * Pass `options.idempotency` to de-duplicate `event_callback` deliveries: Slack\n * redelivers events (with an `X-Slack-Retry-Num` header) on a slow/failed ack,\n * so wrapping the handler in an {@link IdempotencyStore} keyed by\n * {@link slackEventIdempotencyKey} runs it at most once per `event_id`. A\n * `duplicate`/`dropped` outcome skips the handler and acks (returns `undefined`).\n * When `options.idempotency` is omitted, behaviour is unchanged.\n */\n async dispatchEvent(body: SlackEventsRequest, options?: { idempotency?: IdempotencyStore }): Promise<SlackEventsResponse> {\n if (body.type === 'url_verification') {\n return { challenge: (body as { challenge: string }).challenge };\n }\n\n if (body.type === 'event_callback') {\n const envelope = body as SlackEventCallback;\n const handleEvent = async (): Promise<void> => {\n const handler = this.events.get(envelope.event.type);\n if (handler) {\n await handler.handle(envelope.event, {\n teamId: envelope.team_id,\n eventId: envelope.event_id,\n eventTime: envelope.event_time,\n envelope,\n });\n } else {\n this.logger.debug('No Slack event handler registered for event type', { type: envelope.event.type });\n }\n };\n\n if (options?.idempotency) {\n const key = slackEventIdempotencyKey(envelope);\n const outcome = await options.idempotency.deduplicate(key, handleEvent);\n if (outcome.status === 'dropped') {\n this.logger.warn('Slack event dead-lettered after repeated failures', { key, attempts: outcome.attempts });\n }\n return undefined;\n }\n\n await handleEvent();\n return undefined;\n }\n\n this.logger.debug('Unhandled Slack events payload type', { type: body.type });\n return undefined;\n }\n\n /**\n * Dispatch a parsed slash-command payload.\n *\n * Looks up a handler in {@link SlackCommandHandlerMap} keyed by\n * `payload.command` (e.g. `/deploy`). If the handler returns a response,\n * the caller serializes it as JSON; otherwise the caller acks with `200 ''`\n * and the handler is expected to follow up via `payload.response_url`.\n */\n async dispatchCommand(payload: SlackCommandPayload): Promise<SlackCommandResponse | void> {\n const handler = this.commands.get(payload.command);\n if (!handler) {\n this.logger.debug('No Slack command handler registered', { command: payload.command });\n return undefined;\n }\n return await handler.handle(payload);\n }\n\n /**\n * Dispatch a parsed interactive payload (block actions, view submission,\n * shortcut, etc.). Computes a routing key via {@link interactionRouteKey}\n * and looks it up in {@link SlackInteractionHandlerMap}.\n */\n async dispatchInteraction(payload: SlackInteractionPayload): Promise<SlackInteractionResponse | void> {\n const key = interactionRouteKey(payload);\n if (!key) {\n this.logger.debug('Slack interaction payload missing routable identifier', { type: payload.type });\n return undefined;\n }\n const handler = this.interactions.get(key);\n if (!handler) {\n this.logger.debug('No Slack interaction handler registered', { key });\n return undefined;\n }\n return await handler.handle(payload);\n }\n}\n","import { Injectable } from 'injectkit';\nimport { WebClient } from '@slack/web-api';\nimport type {\n WebClientOptions,\n ChatPostMessageArguments,\n ChatPostMessageResponse,\n ChatUpdateArguments,\n ChatUpdateResponse,\n ChatDeleteArguments,\n ChatDeleteResponse,\n ViewsOpenArguments,\n ViewsOpenResponse,\n} from '@slack/web-api';\nimport { Logger } from '@maroonedsoftware/logger';\nimport { SlackConfig } from '../slack.config.js';\nimport { SlackError } from '../slack.error.js';\nimport { adaptLogger } from './slack.logger.adapter.js';\n\n/** Default per-request timeout (ms) applied to outbound `postWebhook` calls. */\nexport const SLACK_DEFAULT_REQUEST_TIMEOUT_MS = 10_000;\n\n/**\n * Redacts a Slack webhook / `response_url` so it is safe to log. The final path\n * segment is the secret token (and the query string can carry secrets too), so\n * both are stripped, leaving only the host and path prefix.\n */\nexport const redactSlackUrl = (raw: string): string => {\n try {\n const url = new URL(raw);\n const segments = url.pathname.split('/').filter(Boolean);\n if (segments.length > 0) segments[segments.length - 1] = '***';\n return `${url.origin}/${segments.join('/')}`;\n } catch {\n return '***';\n }\n};\n\n/**\n * Payload for an incoming-webhook POST. Mirrors the subset of fields Slack's\n * incoming webhooks accept (text, blocks, attachments, response shaping).\n * The body is JSON-stringified verbatim, so any extra fields are preserved.\n */\nexport type IncomingWebhookPayload = {\n text?: string;\n blocks?: unknown[];\n attachments?: unknown[];\n thread_ts?: string;\n response_type?: 'in_channel' | 'ephemeral';\n replace_original?: boolean;\n delete_original?: boolean;\n unfurl_links?: boolean;\n unfurl_media?: boolean;\n [key: string]: unknown;\n};\n\n/**\n * Thin DI-friendly wrapper around `@slack/web-api`'s `WebClient`. Constructed\n * once per request scope (or as a singleton, depending on how the consumer\n * registers it) and exposes typed passthroughs for the most common Web API\n * methods plus a `postWebhook` helper for incoming-webhook URLs and the\n * `response_url` returned by slash commands and interactive payloads.\n *\n * Reach for {@link SlackClient.web} directly for anything else the underlying\n * client supports.\n *\n * @example\n * ```ts\n * await container.get(SlackClient).postMessage({ channel: '#ops', text: 'hello' });\n * await container.get(SlackClient).postWebhook({ text: 'follow-up' }, payload.response_url);\n * ```\n */\n@Injectable()\nexport class SlackClient {\n /** Underlying `@slack/web-api` client. */\n readonly web: WebClient;\n\n /** Web API client authenticated with the app token, built on first use by {@link openSocketModeUrl}. */\n private appWeb?: WebClient;\n\n constructor(\n private readonly config: SlackConfig,\n private readonly logger: Logger,\n ) {\n this.web = new WebClient(config.botToken, this.webClientOptions());\n }\n\n /** Options shared by every `WebClient` this class builds, passing only what the config sets. */\n private webClientOptions(): WebClientOptions {\n return {\n logger: adaptLogger(this.logger),\n ...(this.config.fetch ? { fetch: this.config.fetch } : {}),\n ...(this.config.apiBaseUrl ? { slackApiUrl: this.config.apiBaseUrl } : {}),\n };\n }\n\n /** Posts a message via `chat.postMessage`. */\n postMessage(args: ChatPostMessageArguments): Promise<ChatPostMessageResponse> {\n return this.web.chat.postMessage(args);\n }\n\n /** Updates a message via `chat.update`. */\n updateMessage(args: ChatUpdateArguments): Promise<ChatUpdateResponse> {\n return this.web.chat.update(args);\n }\n\n /** Deletes a message via `chat.delete`. */\n deleteMessage(args: ChatDeleteArguments): Promise<ChatDeleteResponse> {\n return this.web.chat.delete(args);\n }\n\n /** Opens a modal view via `views.open`. */\n openView(args: ViewsOpenArguments): Promise<ViewsOpenResponse> {\n return this.web.views.open(args);\n }\n\n /**\n * POSTs a payload to a Slack incoming-webhook-style URL — either the\n * configured `incomingWebhookUrl` or an explicit URL (e.g. the\n * `response_url` from a slash command or interactive payload).\n *\n * @throws {@link SlackError} if no URL is available or the response is non-2xx.\n */\n async postWebhook(payload: IncomingWebhookPayload, url?: string): Promise<void> {\n const target = url ?? this.config.incomingWebhookUrl;\n if (!target) {\n throw new SlackError('SlackClient.postWebhook called but no incomingWebhookUrl is configured and no url was provided');\n }\n // `target` is a response_url / incoming-webhook URL whose last path segment\n // is a secret — redact it before it reaches the log or internalDetails.\n const safeUrl = redactSlackUrl(target);\n const fetcher = this.config.fetch ?? fetch;\n\n let response: Awaited<ReturnType<typeof fetcher>>;\n try {\n response = await fetcher(target, {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify(payload),\n signal: AbortSignal.timeout(this.config.requestTimeoutMs ?? SLACK_DEFAULT_REQUEST_TIMEOUT_MS),\n });\n } catch (error) {\n // Deliberately not `withCause(error)`: the cause's message can quote the URL, and the URL\n // holds the secret.\n const reason = (error instanceof Error ? error.message : String(error)).split(target).join(safeUrl);\n this.logger.warn('Slack webhook POST did not reach Slack', { url: safeUrl, reason });\n throw new SlackError('Slack webhook POST did not reach Slack').withInternalDetails({ url: safeUrl, reason });\n }\n if (!response.ok) {\n const body = await response.text().catch(() => '');\n this.logger.warn('Slack webhook POST returned non-OK status', { status: response.status, body, url: safeUrl });\n throw new SlackError(`Slack webhook POST returned ${response.status}`).withInternalDetails({ status: response.status, body, url: safeUrl });\n }\n }\n\n /**\n * Opens a Socket Mode connection slot via `apps.connections.open` and returns the WebSocket URL\n * to connect to. Authenticates with `appToken`, not the bot token, over the same `fetch` and base\n * URL as every other call. Each URL is single-use, so call this again for every reconnect.\n *\n * @throws {@link SlackError} if `appToken` is not configured or Slack does not hand back a URL.\n */\n async openSocketModeUrl(): Promise<string> {\n const appToken = this.config.appToken;\n if (!appToken) {\n throw new SlackError('SlackClient.openSocketModeUrl called but no appToken (xapp-...) is configured');\n }\n this.appWeb ??= new WebClient(appToken, this.webClientOptions());\n\n let result: { ok?: boolean; url?: string; error?: string };\n try {\n result = await this.appWeb.apps.connections.open();\n } catch (error) {\n // The SDK throws on `ok: false`, carrying Slack's error code in `data.error`. Its errors do\n // not quote the token, but only the code and message are kept, to be safe.\n const code = (error as { data?: { error?: unknown } }).data?.error;\n const reason = error instanceof Error ? error.message.split(appToken).join('<token>') : String(error);\n this.logger.warn('Slack apps.connections.open failed', { error: code, reason });\n throw new SlackError('Slack apps.connections.open failed').withInternalDetails({ error: code, reason });\n }\n\n if (!result.ok || !result.url) {\n this.logger.warn('Slack apps.connections.open returned no URL', { error: result.error });\n throw new SlackError('Slack apps.connections.open returned no URL').withInternalDetails({ error: result.error });\n }\n return result.url;\n }\n}\n","import type { Logger as SlackLogger, LogLevel } from '@slack/web-api';\nimport { Logger } from '@maroonedsoftware/logger';\n\n/**\n * Adapts a ServerKit {@link Logger} to the `@slack/web-api` {@link SlackLogger}\n * interface so the WebClient can route its diagnostics through the host\n * application's logger.\n *\n * The Slack SDK's logger calls `logger.info(...args)` with a variable number\n * of arguments and no separate \"primary message\"; the adapter forwards them\n * to ServerKit's `(message, ...optionalParams)` shape, with an empty-string\n * primary when no args are passed.\n *\n * `setLevel`, `setName`, and `getLevel` are stored locally — ServerKit\n * loggers do not expose these knobs but the SDK expects them on its logger.\n *\n * @param logger - The ServerKit logger to forward calls to.\n * @param name - Initial value for the SDK logger's name. Defaults to `'slack-web-api'`.\n * @returns A `@slack/web-api`-compatible logger object.\n */\nexport const adaptLogger = (logger: Logger, name = 'slack-web-api'): SlackLogger => {\n const state = { name, level: 'info' as LogLevel };\n const forward =\n (fn: (message: unknown, ...optionalParams: unknown[]) => void) =>\n (...msg: unknown[]) => {\n const [first, ...rest] = msg;\n fn(first ?? '', ...rest);\n };\n return {\n debug: forward(logger.debug.bind(logger)),\n info: forward(logger.info.bind(logger)),\n warn: forward(logger.warn.bind(logger)),\n error: forward(logger.error.bind(logger)),\n setLevel: (level: LogLevel) => {\n state.level = level;\n },\n getLevel: () => state.level,\n setName: (n: string) => {\n state.name = n;\n },\n };\n};\n"],"mappings":"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","names":["Injectable","SlackConfig","createHmac","timingSafeEqual","DateTime","SLACK_SIGNATURE_DEFAULT_MAX_AGE_SECONDS","verifySlackSignature","input","signingSecret","rawBody","timestamp","signature","maxAgeSeconds","now","Math","floor","DateTime","toSeconds","SlackError","withInternalDetails","reason","ts","Number","isFinite","isInteger","abs","expected","createHmac","update","digest","expectedBuf","Buffer","from","providedBuf","length","timingSafeEqual","Injectable","Policy","SLACK_SIGNATURE_POLICY","SLACK_REQUEST_TIMESTAMP_HEADER","SLACK_SIGNATURE_HEADER","SlackSignaturePolicy","Policy","evaluate","context","envelope","rawBody","getHeader","options","body","Buffer","from","toString","verifySlackSignature","signingSecret","timestamp","signature","maxAgeSeconds","signatureMaxAgeSeconds","now","Math","floor","toSeconds","allow","error","IsSlackError","internalDetails","reason","deny","undefined","message","slackEventIdempotencyKey","envelope","team_id","event_id","interactionRouteKey","payload","type","id","actions","action_id","undefined","view","callback_id","Injectable","Logger","SlackCommandHandlerMap","Map","SlackEventHandlerMap","SlackInteractionHandlerMap","SlackDispatcher","events","commands","interactions","logger","dispatchEvent","body","options","type","challenge","envelope","handleEvent","handler","get","event","handle","teamId","team_id","eventId","event_id","eventTime","event_time","debug","idempotency","key","slackEventIdempotencyKey","outcome","deduplicate","status","warn","attempts","undefined","dispatchCommand","payload","command","dispatchInteraction","interactionRouteKey","Injectable","WebClient","Logger","adaptLogger","logger","name","state","level","forward","fn","msg","first","rest","debug","bind","info","warn","error","setLevel","getLevel","setName","n","SLACK_DEFAULT_REQUEST_TIMEOUT_MS","redactSlackUrl","raw","url","URL","segments","pathname","split","filter","Boolean","length","origin","join","SlackClient","web","appWeb","config","logger","WebClient","botToken","webClientOptions","adaptLogger","fetch","apiBaseUrl","slackApiUrl","postMessage","args","chat","updateMessage","update","deleteMessage","delete","openView","views","open","postWebhook","payload","target","incomingWebhookUrl","SlackError","safeUrl","fetcher","response","method","headers","body","JSON","stringify","signal","AbortSignal","timeout","requestTimeoutMs","error","reason","Error","message","String","warn","withInternalDetails","ok","text","catch","status","openSocketModeUrl","appToken","result","apps","connections","code","data"]}
|
package/dist/slack.config.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { FetchFunction } from '@slack/web-api';
|
|
1
2
|
/**
|
|
2
3
|
* Configuration for the Slack package. Declared as an abstract `@Injectable()`
|
|
3
4
|
* class so it doubles as a DI token (mirrors the `Logger` pattern in
|
|
@@ -16,8 +17,18 @@
|
|
|
16
17
|
export interface SlackConfig {
|
|
17
18
|
/** Bot user OAuth token (`xoxb-...`). Required for Web API calls. */
|
|
18
19
|
botToken: string;
|
|
19
|
-
/**
|
|
20
|
-
|
|
20
|
+
/**
|
|
21
|
+
* App-level signing secret used to verify request signatures. Needed only when Slack calls you
|
|
22
|
+
* over HTTP; a Socket Mode app can leave it unset, and signature verification then fails closed
|
|
23
|
+
* with `missing_signing_secret`.
|
|
24
|
+
*/
|
|
25
|
+
signingSecret?: string;
|
|
26
|
+
/**
|
|
27
|
+
* App-level token (`xapp-...`) with the `connections:write` scope. Needed only for Socket Mode,
|
|
28
|
+
* where {@link import('./client/slack.client.js').SlackClient.openSocketModeUrl} trades it for a
|
|
29
|
+
* WebSocket URL.
|
|
30
|
+
*/
|
|
31
|
+
appToken?: string;
|
|
21
32
|
/** Optional incoming webhook URL used as the default target for `SlackClient.postWebhook`. */
|
|
22
33
|
incomingWebhookUrl?: string;
|
|
23
34
|
/**
|
|
@@ -31,7 +42,26 @@ export interface SlackConfig {
|
|
|
31
42
|
* {@link import('./client/slack.client.js').SLACK_DEFAULT_REQUEST_TIMEOUT_MS} (10s).
|
|
32
43
|
*/
|
|
33
44
|
requestTimeoutMs?: number;
|
|
45
|
+
/**
|
|
46
|
+
* Base URL for Web API calls, forwarded to `@slack/web-api` as `slackApiUrl`. Defaults to the
|
|
47
|
+
* SDK's own (`https://slack.com/api/`).
|
|
48
|
+
*/
|
|
49
|
+
apiBaseUrl?: string;
|
|
50
|
+
/**
|
|
51
|
+
* The `fetch` every outbound call goes through: the Web API client, `postWebhook`, and
|
|
52
|
+
* `openSocketModeUrl`. Defaults to the global `fetch`.
|
|
53
|
+
*
|
|
54
|
+
* Set it when the caller owns the transport: a host that routes outbound HTTP through its own
|
|
55
|
+
* allowlist, rate limits or proxy, or a test. The client passes an `AbortSignal` carrying its
|
|
56
|
+
* timeout; an implementation that enforces its own deadline as well may ignore it.
|
|
57
|
+
*/
|
|
58
|
+
fetch?: SlackFetch;
|
|
34
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* The `fetch` shape the client needs. It is `@slack/web-api`'s own `FetchFunction`, so one
|
|
62
|
+
* function serves both the Web API client and the webhook POSTs; the global `fetch` satisfies it.
|
|
63
|
+
*/
|
|
64
|
+
export type SlackFetch = FetchFunction;
|
|
35
65
|
export declare abstract class SlackConfig implements SlackConfig {
|
|
36
66
|
}
|
|
37
67
|
//# sourceMappingURL=slack.config.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"slack.config.d.ts","sourceRoot":"","sources":["../src/slack.config.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"slack.config.d.ts","sourceRoot":"","sources":["../src/slack.config.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAEpD;;;;;;;;;;;;;;GAcG;AACH,MAAM,WAAW,WAAW;IAC1B,qEAAqE;IACrE,QAAQ,EAAE,MAAM,CAAC;IACjB;;;;OAIG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,8FAA8F;IAC9F,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B;;;OAGG;IACH,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAChC;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;;;;;OAOG;IACH,KAAK,CAAC,EAAE,UAAU,CAAC;CACpB;AAED;;;GAGG;AACH,MAAM,MAAM,UAAU,GAAG,aAAa,CAAC;AAEvC,8BACsB,WAAY,YAAW,WAAW;CAAG"}
|
|
@@ -5,14 +5,18 @@ export declare const SLACK_SIGNATURE_DEFAULT_MAX_AGE_SECONDS = 300;
|
|
|
5
5
|
* fails. Useful for callers that want to log structured reasons without
|
|
6
6
|
* pattern-matching on error messages.
|
|
7
7
|
*/
|
|
8
|
-
export type SlackSignatureFailureReason = 'missing_timestamp' | 'invalid_timestamp' | 'stale_timestamp' | 'missing_signature' | 'invalid_signature';
|
|
8
|
+
export type SlackSignatureFailureReason = 'missing_signing_secret' | 'missing_timestamp' | 'invalid_timestamp' | 'stale_timestamp' | 'missing_signature' | 'invalid_signature';
|
|
9
9
|
/**
|
|
10
10
|
* Inputs to {@link verifySlackSignature}. All values are taken verbatim from
|
|
11
11
|
* the request — the helper does no header lookups or body reads of its own.
|
|
12
12
|
*/
|
|
13
13
|
export type VerifySlackSignatureInput = {
|
|
14
|
-
/**
|
|
15
|
-
|
|
14
|
+
/**
|
|
15
|
+
* App signing secret (`SlackConfig.signingSecret`). Optional in the config because a Socket
|
|
16
|
+
* Mode app never verifies a request; verification without one fails with
|
|
17
|
+
* `missing_signing_secret` rather than checking against an empty key.
|
|
18
|
+
*/
|
|
19
|
+
signingSecret: string | undefined;
|
|
16
20
|
/** Raw, unparsed request body — exactly as Slack sent it. */
|
|
17
21
|
rawBody: string;
|
|
18
22
|
/** Value of the `X-Slack-Request-Timestamp` header. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"slack.signature.d.ts","sourceRoot":"","sources":["../src/slack.signature.ts"],"names":[],"mappings":"AAIA,gGAAgG;AAChG,eAAO,MAAM,uCAAuC,MAAM,CAAC;AAE3D;;;;GAIG;AACH,MAAM,MAAM,2BAA2B,GAAG,mBAAmB,GAAG,mBAAmB,GAAG,iBAAiB,GAAG,mBAAmB,GAAG,mBAAmB,CAAC;
|
|
1
|
+
{"version":3,"file":"slack.signature.d.ts","sourceRoot":"","sources":["../src/slack.signature.ts"],"names":[],"mappings":"AAIA,gGAAgG;AAChG,eAAO,MAAM,uCAAuC,MAAM,CAAC;AAE3D;;;;GAIG;AACH,MAAM,MAAM,2BAA2B,GACrC,wBAAwB,GAAG,mBAAmB,GAAG,mBAAmB,GAAG,iBAAiB,GAAG,mBAAmB,GAAG,mBAAmB,CAAC;AAEvI;;;GAGG;AACH,MAAM,MAAM,yBAAyB,GAAG;IACtC;;;;OAIG;IACH,aAAa,EAAE,MAAM,GAAG,SAAS,CAAC;IAClC,6DAA6D;IAC7D,OAAO,EAAE,MAAM,CAAC;IAChB,uDAAuD;IACvD,SAAS,EAAE,MAAM,GAAG,SAAS,CAAC;IAC9B,qEAAqE;IACrE,SAAS,EAAE,MAAM,GAAG,SAAS,CAAC;IAC9B;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;OAGG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC;CACd,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,eAAO,MAAM,oBAAoB,GAAI,OAAO,yBAAyB,KAAG,IA2DvE,CAAC"}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The minimal WebSocket contract {@link import('./client/slack.socket.mode.client.js').SocketModeClient}
|
|
3
|
+
* drives. The caller supplies it, so ServerKit never opens a connection of its own: wrap the
|
|
4
|
+
* platform `WebSocket`, the `ws` package, or a host's egress-checked socket.
|
|
5
|
+
*
|
|
6
|
+
* Text frames only. There is no `onOpen`: the client sends nothing until Slack speaks first, and a
|
|
7
|
+
* transport error is expected to surface as a close.
|
|
8
|
+
*/
|
|
9
|
+
export interface SocketLike {
|
|
10
|
+
/** Sends one text frame. */
|
|
11
|
+
send(text: string): void;
|
|
12
|
+
/** Closes the socket. The client does not expect `onClose` to be skipped for a close it asked for. */
|
|
13
|
+
close(code?: number, reason?: string): void;
|
|
14
|
+
/** Registers the listener for every inbound text frame. */
|
|
15
|
+
onMessage(listener: (text: string) => void): void;
|
|
16
|
+
/** Registers the listener for the socket closing, for whatever reason. */
|
|
17
|
+
onClose(listener: (code?: number, reason?: string) => void): void;
|
|
18
|
+
}
|
|
19
|
+
/** Opens a {@link SocketLike} to `url`. May answer synchronously or with a promise. */
|
|
20
|
+
export type SocketConnect = (url: string) => SocketLike | Promise<SocketLike>;
|
|
21
|
+
//# sourceMappingURL=slack.socket.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"slack.socket.d.ts","sourceRoot":"","sources":["../src/slack.socket.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,MAAM,WAAW,UAAU;IACzB,4BAA4B;IAC5B,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,sGAAsG;IACtG,KAAK,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5C,2DAA2D;IAC3D,SAAS,CAAC,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,GAAG,IAAI,CAAC;IAClD,0EAA0E;IAC1E,OAAO,CAAC,QAAQ,EAAE,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,KAAK,IAAI,GAAG,IAAI,CAAC;CACnE;AAED,uFAAuF;AACvF,MAAM,MAAM,aAAa,GAAG,CAAC,GAAG,EAAE,MAAM,KAAK,UAAU,GAAG,OAAO,CAAC,UAAU,CAAC,CAAC"}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@maroonedsoftware/slack/socketmode` — a Slack Socket Mode client over a
|
|
3
|
+
* socket the caller supplies. Kept off the root barrel so an HTTP-only app
|
|
4
|
+
* never loads it.
|
|
5
|
+
*/
|
|
6
|
+
export * from './slack.socket.js';
|
|
7
|
+
export * from './client/slack.socket.mode.client.js';
|
|
8
|
+
//# sourceMappingURL=socketmode.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"socketmode.d.ts","sourceRoot":"","sources":["../src/socketmode.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,cAAc,mBAAmB,CAAC;AAClC,cAAc,sCAAsC,CAAC"}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SlackError
|
|
3
|
+
} from "./chunk-22F5RFRD.js";
|
|
4
|
+
import {
|
|
5
|
+
__name
|
|
6
|
+
} from "./chunk-7QVYU63E.js";
|
|
7
|
+
|
|
8
|
+
// src/client/slack.socket.mode.client.ts
|
|
9
|
+
var SOCKET_MODE_DEFAULT_BACKOFF_INITIAL_MS = 1e3;
|
|
10
|
+
var SOCKET_MODE_DEFAULT_BACKOFF_MAX_MS = 3e4;
|
|
11
|
+
var SocketModeClient = class {
|
|
12
|
+
static {
|
|
13
|
+
__name(this, "SocketModeClient");
|
|
14
|
+
}
|
|
15
|
+
options;
|
|
16
|
+
socket;
|
|
17
|
+
/** Bumped for every socket, so events from a socket already replaced are ignored. */
|
|
18
|
+
generation = 0;
|
|
19
|
+
attempts = 0;
|
|
20
|
+
reconnectTimer;
|
|
21
|
+
stopped = true;
|
|
22
|
+
ready = false;
|
|
23
|
+
constructor(options) {
|
|
24
|
+
this.options = options;
|
|
25
|
+
}
|
|
26
|
+
/** Whether the current connection has received `hello`. */
|
|
27
|
+
get isReady() {
|
|
28
|
+
return this.ready;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Opens the first connection. Resolves once the socket is open, not on `hello`.
|
|
32
|
+
*
|
|
33
|
+
* @throws Whatever `openUrl` or `connect` throws for that first attempt, so a bad token or a
|
|
34
|
+
* refused host surfaces to the caller. Later reconnects retry with backoff instead.
|
|
35
|
+
*/
|
|
36
|
+
async start() {
|
|
37
|
+
if (!this.stopped) return;
|
|
38
|
+
this.stopped = false;
|
|
39
|
+
this.attempts = 0;
|
|
40
|
+
try {
|
|
41
|
+
await this.open();
|
|
42
|
+
} catch (error) {
|
|
43
|
+
this.stopped = true;
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** Closes the connection and cancels any pending reconnect. It never reconnects after this. */
|
|
48
|
+
stop() {
|
|
49
|
+
this.stopped = true;
|
|
50
|
+
this.ready = false;
|
|
51
|
+
clearTimeout(this.reconnectTimer);
|
|
52
|
+
this.reconnectTimer = void 0;
|
|
53
|
+
this.generation++;
|
|
54
|
+
const socket = this.socket;
|
|
55
|
+
this.socket = void 0;
|
|
56
|
+
socket?.close(1e3, "stopped");
|
|
57
|
+
}
|
|
58
|
+
/** Opens a fresh URL and socket, and makes it current. Any previous socket is closed after. */
|
|
59
|
+
async open() {
|
|
60
|
+
const url = await this.options.openUrl();
|
|
61
|
+
if (this.stopped) return;
|
|
62
|
+
const socket = await this.options.connect(url);
|
|
63
|
+
if (this.stopped) {
|
|
64
|
+
socket.close(1e3, "stopped");
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const generation = ++this.generation;
|
|
68
|
+
const previous = this.socket;
|
|
69
|
+
this.socket = socket;
|
|
70
|
+
this.ready = false;
|
|
71
|
+
clearTimeout(this.reconnectTimer);
|
|
72
|
+
this.reconnectTimer = void 0;
|
|
73
|
+
socket.onMessage((text) => {
|
|
74
|
+
if (generation === this.generation) this.onFrame(socket, text);
|
|
75
|
+
});
|
|
76
|
+
socket.onClose((code, reason) => {
|
|
77
|
+
if (generation === this.generation) this.onClose(code, reason);
|
|
78
|
+
});
|
|
79
|
+
previous?.close(1e3, "replaced");
|
|
80
|
+
}
|
|
81
|
+
onFrame(socket, text) {
|
|
82
|
+
let frame;
|
|
83
|
+
try {
|
|
84
|
+
frame = JSON.parse(text);
|
|
85
|
+
} catch {
|
|
86
|
+
this.options.logger.warn("Slack Socket Mode frame is not JSON", {
|
|
87
|
+
length: text.length
|
|
88
|
+
});
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (frame.envelope_id) {
|
|
92
|
+
socket.send(JSON.stringify({
|
|
93
|
+
envelope_id: frame.envelope_id
|
|
94
|
+
}));
|
|
95
|
+
}
|
|
96
|
+
switch (frame.type) {
|
|
97
|
+
case "hello":
|
|
98
|
+
this.ready = true;
|
|
99
|
+
this.attempts = 0;
|
|
100
|
+
this.options.logger.info("Slack Socket Mode connected");
|
|
101
|
+
return;
|
|
102
|
+
case "disconnect":
|
|
103
|
+
this.onDisconnect(frame.reason);
|
|
104
|
+
return;
|
|
105
|
+
case "events_api":
|
|
106
|
+
this.dispatch(this.options.handlers.onEventsApi, frame);
|
|
107
|
+
return;
|
|
108
|
+
case "slash_commands":
|
|
109
|
+
this.dispatch(this.options.handlers.onSlashCommand, frame);
|
|
110
|
+
return;
|
|
111
|
+
case "interactive":
|
|
112
|
+
this.dispatch(this.options.handlers.onInteractive, frame);
|
|
113
|
+
return;
|
|
114
|
+
default:
|
|
115
|
+
this.options.logger.debug("Slack Socket Mode frame ignored", {
|
|
116
|
+
type: frame.type
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
dispatch(handler, frame) {
|
|
121
|
+
if (!handler || !frame.envelope_id) {
|
|
122
|
+
this.options.logger.debug("Slack Socket Mode envelope has no handler", {
|
|
123
|
+
type: frame.type
|
|
124
|
+
});
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const meta = {
|
|
128
|
+
envelopeId: frame.envelope_id,
|
|
129
|
+
...frame.retry_attempt !== void 0 ? {
|
|
130
|
+
retryAttempt: frame.retry_attempt
|
|
131
|
+
} : {},
|
|
132
|
+
...frame.retry_reason !== void 0 ? {
|
|
133
|
+
retryReason: frame.retry_reason
|
|
134
|
+
} : {}
|
|
135
|
+
};
|
|
136
|
+
Promise.resolve().then(() => handler(frame.payload, meta)).catch((error) => {
|
|
137
|
+
this.options.logger.error("Slack Socket Mode handler failed", {
|
|
138
|
+
type: frame.type,
|
|
139
|
+
envelopeId: frame.envelope_id,
|
|
140
|
+
error
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
onDisconnect(reason) {
|
|
145
|
+
if (reason === "link_disabled") {
|
|
146
|
+
this.fail(new SlackError("Slack disabled the Socket Mode link").withInternalDetails({
|
|
147
|
+
reason
|
|
148
|
+
}));
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
this.options.logger.info("Slack Socket Mode asked to reconnect", {
|
|
152
|
+
reason
|
|
153
|
+
});
|
|
154
|
+
this.open().catch((error) => {
|
|
155
|
+
this.options.logger.warn("Slack Socket Mode refresh failed; backing off", {
|
|
156
|
+
error
|
|
157
|
+
});
|
|
158
|
+
this.scheduleReconnect();
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
onClose(code, reason) {
|
|
162
|
+
if (this.stopped) return;
|
|
163
|
+
this.ready = false;
|
|
164
|
+
this.socket = void 0;
|
|
165
|
+
this.options.logger.warn("Slack Socket Mode connection closed", {
|
|
166
|
+
code,
|
|
167
|
+
reason
|
|
168
|
+
});
|
|
169
|
+
this.scheduleReconnect();
|
|
170
|
+
}
|
|
171
|
+
scheduleReconnect() {
|
|
172
|
+
if (this.stopped || this.reconnectTimer) return;
|
|
173
|
+
const initial = this.options.backoff?.initialMs ?? SOCKET_MODE_DEFAULT_BACKOFF_INITIAL_MS;
|
|
174
|
+
const max = this.options.backoff?.maxMs ?? SOCKET_MODE_DEFAULT_BACKOFF_MAX_MS;
|
|
175
|
+
const delay = Math.min(initial * 2 ** this.attempts, max);
|
|
176
|
+
this.attempts++;
|
|
177
|
+
this.reconnectTimer = setTimeout(() => {
|
|
178
|
+
this.reconnectTimer = void 0;
|
|
179
|
+
this.open().catch((error) => {
|
|
180
|
+
this.options.logger.warn("Slack Socket Mode reconnect failed; backing off", {
|
|
181
|
+
error
|
|
182
|
+
});
|
|
183
|
+
this.scheduleReconnect();
|
|
184
|
+
});
|
|
185
|
+
}, delay);
|
|
186
|
+
}
|
|
187
|
+
fail(error) {
|
|
188
|
+
this.options.logger.error(error.message, error.internalDetails);
|
|
189
|
+
this.stop();
|
|
190
|
+
this.options.onError?.(error);
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
export {
|
|
194
|
+
SOCKET_MODE_DEFAULT_BACKOFF_INITIAL_MS,
|
|
195
|
+
SOCKET_MODE_DEFAULT_BACKOFF_MAX_MS,
|
|
196
|
+
SocketModeClient
|
|
197
|
+
};
|
|
198
|
+
//# sourceMappingURL=socketmode.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/client/slack.socket.mode.client.ts"],"sourcesContent":["import { Logger } from '@maroonedsoftware/logger';\nimport type { SlackCommandPayload } from '../slack.command.handler.js';\nimport { SlackError } from '../slack.error.js';\nimport type { SlackEventCallback } from '../slack.event.handler.js';\nimport type { SlackInteractionPayload } from '../slack.interaction.handler.js';\nimport type { SocketConnect, SocketLike } from '../slack.socket.js';\n\n/** Default first reconnect delay (ms) after an unexpected close. */\nexport const SOCKET_MODE_DEFAULT_BACKOFF_INITIAL_MS = 1_000;\n/** Default ceiling (ms) the reconnect delay doubles up to. */\nexport const SOCKET_MODE_DEFAULT_BACKOFF_MAX_MS = 30_000;\n\n/** Envelope metadata handed to every Socket Mode handler alongside the payload. */\nexport type SocketModeEnvelopeMeta = {\n /** The envelope id, already acknowledged by the time a handler runs. */\n envelopeId: string;\n /** How many times Slack has sent this envelope before (Events API only). */\n retryAttempt?: number;\n /** Why Slack is retrying (Events API only). */\n retryReason?: string;\n};\n\n/**\n * Handlers for the three Socket Mode payload types. Each runs **after** the envelope has been\n * acknowledged, so a slow handler never misses Slack's 3-second window, and none can shape the ack:\n * reply through the payload's `response_url` or the Web API instead. A handler that throws or\n * rejects is logged and does not stop the client.\n */\nexport type SocketModeHandlers = {\n /** An Events API delivery (`event_callback`). */\n onEventsApi?: (payload: SlackEventCallback, meta: SocketModeEnvelopeMeta) => unknown;\n /** A slash command. */\n onSlashCommand?: (payload: SlackCommandPayload, meta: SocketModeEnvelopeMeta) => unknown;\n /** An interactive payload (`block_actions`, `view_submission`, `shortcut`, …). */\n onInteractive?: (payload: SlackInteractionPayload, meta: SocketModeEnvelopeMeta) => unknown;\n};\n\n/** Options for {@link SocketModeClient}. */\nexport type SocketModeClientOptions = {\n /** Returns a fresh, single-use WebSocket URL. Normally `() => slackClient.openSocketModeUrl()`. */\n openUrl: () => Promise<string>;\n /** Opens the caller's socket to a URL `openUrl` returned. */\n connect: SocketConnect;\n /** Where each payload type goes. */\n handlers: SocketModeHandlers;\n logger: Logger;\n /** Called once when the client stops for good on its own, e.g. Slack disabling the link. */\n onError?: (error: SlackError) => void;\n /** Reconnect backoff after an unexpected close: `initialMs` doubling up to `maxMs`. */\n backoff?: { initialMs?: number; maxMs?: number };\n};\n\ntype SocketModeFrame = {\n type?: string;\n envelope_id?: string;\n payload?: unknown;\n reason?: string;\n retry_attempt?: number;\n retry_reason?: string;\n};\n\n/**\n * A Slack Socket Mode client over a socket the caller supplies. It never opens a connection of its\n * own: `openUrl` fetches the URL (through whatever `fetch` the caller configured), and `connect`\n * opens the socket.\n *\n * - Every envelope is acknowledged with `{ envelope_id }` the moment it arrives, before its handler\n * runs.\n * - `hello` marks the client ready.\n * - `disconnect` with `refresh_requested` or `warning` opens a fresh URL and moves over to it before\n * closing the old socket. `link_disabled` stops the client and reports through `onError`.\n * - Any other close reconnects with exponential backoff, until {@link stop}.\n *\n * @example\n * ```ts\n * const socketMode = new SocketModeClient({\n * openUrl: () => slack.openSocketModeUrl(),\n * connect: url => host.socket(url),\n * handlers: { onSlashCommand: payload => slack.postWebhook({ text: 'on it' }, payload.response_url) },\n * logger,\n * });\n * await socketMode.start();\n * ```\n */\nexport class SocketModeClient {\n private socket?: SocketLike;\n /** Bumped for every socket, so events from a socket already replaced are ignored. */\n private generation = 0;\n private attempts = 0;\n private reconnectTimer?: ReturnType<typeof setTimeout>;\n private stopped = true;\n private ready = false;\n\n constructor(private readonly options: SocketModeClientOptions) {}\n\n /** Whether the current connection has received `hello`. */\n get isReady(): boolean {\n return this.ready;\n }\n\n /**\n * Opens the first connection. Resolves once the socket is open, not on `hello`.\n *\n * @throws Whatever `openUrl` or `connect` throws for that first attempt, so a bad token or a\n * refused host surfaces to the caller. Later reconnects retry with backoff instead.\n */\n async start(): Promise<void> {\n if (!this.stopped) return;\n this.stopped = false;\n this.attempts = 0;\n try {\n await this.open();\n } catch (error) {\n this.stopped = true;\n throw error;\n }\n }\n\n /** Closes the connection and cancels any pending reconnect. It never reconnects after this. */\n stop(): void {\n this.stopped = true;\n this.ready = false;\n clearTimeout(this.reconnectTimer);\n this.reconnectTimer = undefined;\n this.generation++;\n const socket = this.socket;\n this.socket = undefined;\n socket?.close(1000, 'stopped');\n }\n\n /** Opens a fresh URL and socket, and makes it current. Any previous socket is closed after. */\n private async open(): Promise<void> {\n const url = await this.options.openUrl();\n if (this.stopped) return;\n const socket = await this.options.connect(url);\n if (this.stopped) {\n socket.close(1000, 'stopped');\n return;\n }\n\n const generation = ++this.generation;\n const previous = this.socket;\n this.socket = socket;\n this.ready = false;\n // A refresh can land while a close-triggered reconnect is pending; this socket supersedes it.\n clearTimeout(this.reconnectTimer);\n this.reconnectTimer = undefined;\n socket.onMessage(text => {\n if (generation === this.generation) this.onFrame(socket, text);\n });\n socket.onClose((code, reason) => {\n if (generation === this.generation) this.onClose(code, reason);\n });\n previous?.close(1000, 'replaced');\n }\n\n private onFrame(socket: SocketLike, text: string): void {\n let frame: SocketModeFrame;\n try {\n frame = JSON.parse(text) as SocketModeFrame;\n } catch {\n this.options.logger.warn('Slack Socket Mode frame is not JSON', { length: text.length });\n return;\n }\n\n // Ack first, whatever the envelope holds, so no handler can make Slack wait.\n if (frame.envelope_id) {\n socket.send(JSON.stringify({ envelope_id: frame.envelope_id }));\n }\n\n switch (frame.type) {\n case 'hello':\n this.ready = true;\n this.attempts = 0;\n this.options.logger.info('Slack Socket Mode connected');\n return;\n case 'disconnect':\n this.onDisconnect(frame.reason);\n return;\n case 'events_api':\n this.dispatch(this.options.handlers.onEventsApi, frame);\n return;\n case 'slash_commands':\n this.dispatch(this.options.handlers.onSlashCommand, frame);\n return;\n case 'interactive':\n this.dispatch(this.options.handlers.onInteractive, frame);\n return;\n default:\n this.options.logger.debug('Slack Socket Mode frame ignored', { type: frame.type });\n }\n }\n\n private dispatch<T>(handler: ((payload: T, meta: SocketModeEnvelopeMeta) => unknown) | undefined, frame: SocketModeFrame): void {\n if (!handler || !frame.envelope_id) {\n this.options.logger.debug('Slack Socket Mode envelope has no handler', { type: frame.type });\n return;\n }\n const meta: SocketModeEnvelopeMeta = {\n envelopeId: frame.envelope_id,\n ...(frame.retry_attempt !== undefined ? { retryAttempt: frame.retry_attempt } : {}),\n ...(frame.retry_reason !== undefined ? { retryReason: frame.retry_reason } : {}),\n };\n // Run on a later microtask, so the ack above is always on the wire first.\n Promise.resolve()\n .then(() => handler(frame.payload as T, meta))\n .catch((error: unknown) => {\n this.options.logger.error('Slack Socket Mode handler failed', { type: frame.type, envelopeId: frame.envelope_id, error });\n });\n }\n\n private onDisconnect(reason: string | undefined): void {\n if (reason === 'link_disabled') {\n this.fail(new SlackError('Slack disabled the Socket Mode link').withInternalDetails({ reason }));\n return;\n }\n this.options.logger.info('Slack Socket Mode asked to reconnect', { reason });\n this.open().catch((error: unknown) => {\n this.options.logger.warn('Slack Socket Mode refresh failed; backing off', { error });\n this.scheduleReconnect();\n });\n }\n\n private onClose(code: number | undefined, reason: string | undefined): void {\n if (this.stopped) return;\n this.ready = false;\n this.socket = undefined;\n this.options.logger.warn('Slack Socket Mode connection closed', { code, reason });\n this.scheduleReconnect();\n }\n\n private scheduleReconnect(): void {\n if (this.stopped || this.reconnectTimer) return;\n const initial = this.options.backoff?.initialMs ?? SOCKET_MODE_DEFAULT_BACKOFF_INITIAL_MS;\n const max = this.options.backoff?.maxMs ?? SOCKET_MODE_DEFAULT_BACKOFF_MAX_MS;\n const delay = Math.min(initial * 2 ** this.attempts, max);\n this.attempts++;\n this.reconnectTimer = setTimeout(() => {\n this.reconnectTimer = undefined;\n this.open().catch((error: unknown) => {\n this.options.logger.warn('Slack Socket Mode reconnect failed; backing off', { error });\n this.scheduleReconnect();\n });\n }, delay);\n }\n\n private fail(error: SlackError): void {\n this.options.logger.error(error.message, error.internalDetails);\n this.stop();\n this.options.onError?.(error);\n }\n}\n"],"mappings":"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","names":["SOCKET_MODE_DEFAULT_BACKOFF_INITIAL_MS","SOCKET_MODE_DEFAULT_BACKOFF_MAX_MS","SocketModeClient","socket","generation","attempts","reconnectTimer","stopped","ready","options","isReady","start","open","error","stop","clearTimeout","undefined","close","url","openUrl","connect","previous","onMessage","text","onFrame","onClose","code","reason","frame","JSON","parse","logger","warn","length","envelope_id","send","stringify","type","info","onDisconnect","dispatch","handlers","onEventsApi","onSlashCommand","onInteractive","debug","handler","meta","envelopeId","retry_attempt","retryAttempt","retry_reason","retryReason","Promise","resolve","then","payload","catch","fail","SlackError","withInternalDetails","scheduleReconnect","initial","backoff","initialMs","max","maxMs","delay","Math","min","setTimeout","message","internalDetails","onError"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@maroonedsoftware/slack",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.2.0",
|
|
4
4
|
"description": "Slack utilities for ServerKit.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Marooned Software",
|
|
@@ -34,6 +34,10 @@
|
|
|
34
34
|
"types": "./dist/comms.d.ts",
|
|
35
35
|
"import": "./dist/comms.js"
|
|
36
36
|
},
|
|
37
|
+
"./socketmode": {
|
|
38
|
+
"types": "./dist/socketmode.d.ts",
|
|
39
|
+
"import": "./dist/socketmode.js"
|
|
40
|
+
},
|
|
37
41
|
"./package.json": "./package.json"
|
|
38
42
|
},
|
|
39
43
|
"license": "MIT",
|
|
@@ -45,20 +49,20 @@
|
|
|
45
49
|
"@slack/web-api": "^8.1.1",
|
|
46
50
|
"injectkit": "^1.7.1",
|
|
47
51
|
"luxon": "^3.7.2",
|
|
48
|
-
"@maroonedsoftware/errors": "1.9.
|
|
52
|
+
"@maroonedsoftware/errors": "1.9.1",
|
|
49
53
|
"@maroonedsoftware/logger": "1.1.9",
|
|
50
|
-
"@maroonedsoftware/policies": "0.6.
|
|
54
|
+
"@maroonedsoftware/policies": "0.6.9"
|
|
51
55
|
},
|
|
52
56
|
"devDependencies": {
|
|
53
57
|
"@types/luxon": "^3.7.5",
|
|
54
58
|
"@maroonedsoftware/cache": "0.5.0",
|
|
55
|
-
"@maroonedsoftware/comms": "0.2.10",
|
|
56
59
|
"@repo/config-eslint": "0.2.1",
|
|
60
|
+
"@maroonedsoftware/comms": "0.2.11",
|
|
57
61
|
"@repo/config-typescript": "0.1.0"
|
|
58
62
|
},
|
|
59
63
|
"peerDependencies": {
|
|
60
64
|
"@maroonedsoftware/cache": "0.5.0",
|
|
61
|
-
"@maroonedsoftware/comms": "0.2.
|
|
65
|
+
"@maroonedsoftware/comms": "0.2.11"
|
|
62
66
|
},
|
|
63
67
|
"peerDependenciesMeta": {
|
|
64
68
|
"@maroonedsoftware/cache": {
|
|
@@ -72,7 +76,7 @@
|
|
|
72
76
|
"node": ">=22"
|
|
73
77
|
},
|
|
74
78
|
"scripts": {
|
|
75
|
-
"build": "tsup src/index.ts src/comms.ts --format esm --sourcemap && tsc --emitDeclarationOnly --declaration",
|
|
79
|
+
"build": "tsup src/index.ts src/comms.ts src/socketmode.ts --format esm --sourcemap && tsc --emitDeclarationOnly --declaration",
|
|
76
80
|
"build:ci": "eslint --max-warnings=0 && pnpm run build",
|
|
77
81
|
"lint": "eslint --fix",
|
|
78
82
|
"format": "prettier --write .",
|