@maroonedsoftware/slack 3.0.1 → 3.0.5
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 +206 -0
- package/package.json +9 -8
package/AGENTS.md
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# AGENTS.md — @maroonedsoftware/slack
|
|
2
|
+
|
|
3
|
+
Machine-oriented guide for AI agents. Human prose and long-form examples live in [README.md](./README.md).
|
|
4
|
+
Repo-wide conventions live in the [root AGENTS.md](../../AGENTS.md).
|
|
5
|
+
|
|
6
|
+
## Purpose
|
|
7
|
+
|
|
8
|
+
A Slack dispatcher for ServerKit: signature verification as a policy, DI-registered handler maps for
|
|
9
|
+
events, slash commands, and interactive payloads, a `SlackClient` over `@slack/web-api`, and an
|
|
10
|
+
optional adapter that binds it all to the channel-agnostic `@maroonedsoftware/comms` router.
|
|
11
|
+
|
|
12
|
+
Reach for the native handler maps when you want Slack-specific richness (Block Kit modals,
|
|
13
|
+
`view_submission`). Reach for `./comms` when you want one bot that also runs on Discord, Telegram,
|
|
14
|
+
and WhatsApp. The two coexist: `./comms` normalises the common cases and leaves the rest on the
|
|
15
|
+
native maps.
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pnpm add @maroonedsoftware/slack
|
|
21
|
+
pnpm add @maroonedsoftware/comms # only for the ./comms adapter
|
|
22
|
+
pnpm add @maroonedsoftware/cache # only for idempotency
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Runtime dependencies: `@maroonedsoftware/errors`, `@maroonedsoftware/logger`,
|
|
26
|
+
`@maroonedsoftware/policies`, `@slack/web-api`, `injectkit`, `luxon`. Optional peers:
|
|
27
|
+
`@maroonedsoftware/comms`, `@maroonedsoftware/cache`.
|
|
28
|
+
|
|
29
|
+
## Position in the graph
|
|
30
|
+
|
|
31
|
+
- **Depends on:** `errors`, `logger`, `policies`. `comms` and `cache` are **optional** peers.
|
|
32
|
+
- **Depended on by:** nothing internal.
|
|
33
|
+
- **Subpath exports:**
|
|
34
|
+
- `.` — config, errors, signature verification, handler maps, dispatcher, client.
|
|
35
|
+
- `./comms` — the adapter. Pulls in `@maroonedsoftware/comms`. It lives here, not in `comms`,
|
|
36
|
+
because `comms` must stay channel-free; see the root AGENTS.md.
|
|
37
|
+
|
|
38
|
+
**Not a dependency: `koa`.** Your route parses the request and calls the dispatcher.
|
|
39
|
+
|
|
40
|
+
## API surface
|
|
41
|
+
|
|
42
|
+
### `.` — config and errors
|
|
43
|
+
|
|
44
|
+
| Export | Kind | Shape | Notes |
|
|
45
|
+
| -------------- | -------------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
|
|
46
|
+
| `SlackConfig` | interface + abstract class | `{ botToken, signingSecret, incomingWebhookUrl?, signatureMaxAgeSeconds?, requestTimeoutMs? }` | Declaration-merged so one symbol is type and DI token. |
|
|
47
|
+
| `SlackError` | class | `extends ServerkitError` | — |
|
|
48
|
+
| `IsSlackError` | type guard | `(error: unknown) => error is SlackError` | — |
|
|
49
|
+
|
|
50
|
+
### `.` — signature verification
|
|
51
|
+
|
|
52
|
+
| Export | Kind | Shape | Notes |
|
|
53
|
+
| ----------------------------------------- | --------- | ----------------------------------------------------------- | ------------------------------------------ |
|
|
54
|
+
| `verifySlackSignature` | function | `(input: VerifySlackSignatureInput) => void` | Pure. **Throws** `SlackError` on failure. |
|
|
55
|
+
| `VerifySlackSignatureInput` | type | Raw body, headers, and `SlackSignatureOptions` | — |
|
|
56
|
+
| `SlackSignatureOptions` | type | Signing secret plus max age | — |
|
|
57
|
+
| `SlackSignatureFailureReason` | type | Reason codes landing in `internalDetails.reason` | — |
|
|
58
|
+
| `SlackSignaturePolicy` | class | `extends Policy<SlackSignaturePolicyContext>` | Policy form — denies rather than throwing. |
|
|
59
|
+
| `SlackSignaturePolicyContext` | interface | Structurally compatible with koa's `SignaturePolicyContext` | What lets `requireSignature` drive it. |
|
|
60
|
+
| `SLACK_SIGNATURE_POLICY` | constant | The `PolicyRegistryMap` key | — |
|
|
61
|
+
| `SLACK_SIGNATURE_HEADER` | constant | `'X-Slack-Signature'` | — |
|
|
62
|
+
| `SLACK_REQUEST_TIMESTAMP_HEADER` | constant | `'X-Slack-Request-Timestamp'` | — |
|
|
63
|
+
| `SLACK_SIGNATURE_DEFAULT_MAX_AGE_SECONDS` | constant | Replay window | — |
|
|
64
|
+
|
|
65
|
+
### `.` — handlers and dispatch
|
|
66
|
+
|
|
67
|
+
| Export | Kind | Shape | Notes |
|
|
68
|
+
| ---------------------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- |
|
|
69
|
+
| `SlackEventHandler<TEvent>` | interface | `handle(event, context: SlackEventContext)` | — |
|
|
70
|
+
| `SlackEventHandlerMap` | class | `extends Map<string, SlackEventHandler>` | Keyed by event type (`app_mention`, `message`). |
|
|
71
|
+
| `SlackCommandHandler` | interface | `handle(payload: SlackCommandPayload)` | — |
|
|
72
|
+
| `SlackCommandHandlerMap` | class | `extends Map<string, SlackCommandHandler>` | Keyed by command name. |
|
|
73
|
+
| `SlackInteractionHandler` | interface | `handle(payload: SlackInteractionPayload)` | — |
|
|
74
|
+
| `SlackInteractionHandlerMap` | class | `extends Map<string, SlackInteractionHandler>` | Keyed by `interactionRouteKey(payload)`. |
|
|
75
|
+
| `interactionRouteKey` | function | `(payload) => string \| undefined` | `block_actions:<action_id>`, `view_submission:<callback_id>`, … |
|
|
76
|
+
| `SlackDispatcher` | class | `dispatchEvent(body, options?)`, `dispatchCommand(payload)`, `dispatchInteraction(payload)` | The entry point. |
|
|
77
|
+
| `slackEventIdempotencyKey` | function | `(envelope: Pick<SlackEventCallback, 'event_id' \| 'team_id'>) => string` | Team-scoped, so ids are unique across workspaces. |
|
|
78
|
+
| Payload types | — | `SlackEventsRequest`, `SlackEventsResponse`, `SlackEventCallback`, `SlackEventContext`, `SlackCommandPayload`, `SlackCommandResponse`, `SlackInteractionPayload`, `SlackInteractionResponse`, `SlackInteractionType`, `IncomingWebhookPayload` | — |
|
|
79
|
+
|
|
80
|
+
`dispatchEvent(body, { idempotency })` wraps the handler in `IdempotencyStore.deduplicate` keyed by
|
|
81
|
+
`slackEventIdempotencyKey`. Slack redelivers events, so this is a real redelivery net, not just a
|
|
82
|
+
guard.
|
|
83
|
+
|
|
84
|
+
### `.` — client
|
|
85
|
+
|
|
86
|
+
| Export | Kind | Shape | Notes |
|
|
87
|
+
| ---------------------------------- | -------- | --------------------------------------------------------------- | -------------------------------------- |
|
|
88
|
+
| `SlackClient` | class | `postMessage`, `postWebhook`, … | Over `@slack/web-api`. |
|
|
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 | — | — |
|
|
92
|
+
|
|
93
|
+
### `./comms`
|
|
94
|
+
|
|
95
|
+
| Export | Kind | Shape | Notes |
|
|
96
|
+
| -------------------------- | -------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------- |
|
|
97
|
+
| `createSlackNotifier` | function | `(client: SlackClient, templates: TemplateRegistry) => Notifier` | Recipient is a `response_url` (webhook) **or** a channel id. |
|
|
98
|
+
| `dispatchSlackEvent` | function | `(router, client, body) => Promise<{ challenge: string } \| undefined>` | Returns the `url_verification` challenge; routes `message`/`app_mention`. |
|
|
99
|
+
| `dispatchSlackCommand` | function | `(router, client, payload) => Promise<void>` | Replies via `response_url` when present. |
|
|
100
|
+
| `dispatchSlackInteraction` | function | `(router, client, payload) => Promise<void>` | **Only `block_actions`** is normalised. |
|
|
101
|
+
|
|
102
|
+
## Canonical usage
|
|
103
|
+
|
|
104
|
+
```typescript
|
|
105
|
+
import {
|
|
106
|
+
SlackConfig,
|
|
107
|
+
SlackDispatcher,
|
|
108
|
+
SlackEventHandlerMap,
|
|
109
|
+
SlackSignaturePolicy,
|
|
110
|
+
SLACK_SIGNATURE_POLICY,
|
|
111
|
+
type SlackSignatureOptions,
|
|
112
|
+
} from '@maroonedsoftware/slack';
|
|
113
|
+
|
|
114
|
+
// Composition root
|
|
115
|
+
registry.register(SlackConfig).useValue(appConfig.getAs<SlackConfig>('slack'));
|
|
116
|
+
const events = new SlackEventHandlerMap();
|
|
117
|
+
events.set('app_mention', container.get(MentionHandler));
|
|
118
|
+
registry.register(SlackEventHandlerMap).useValue(events);
|
|
119
|
+
policies.set(SLACK_SIGNATURE_POLICY, SlackSignaturePolicy);
|
|
120
|
+
|
|
121
|
+
// Route — signature first, then dispatch
|
|
122
|
+
router.post('/slack/events', requireSignature<SlackSignatureOptions>('slack', { policy: SLACK_SIGNATURE_POLICY }), async ctx => {
|
|
123
|
+
const body = JSON.parse(ctx.rawBody as string);
|
|
124
|
+
ctx.body = await ctx.container.get(SlackDispatcher).dispatchEvent(body, { idempotency: ctx.container.get(IdempotencyStore) });
|
|
125
|
+
});
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Channel-agnostic instead:
|
|
129
|
+
|
|
130
|
+
```typescript
|
|
131
|
+
import { dispatchSlackCommand, createSlackNotifier } from '@maroonedsoftware/slack/comms';
|
|
132
|
+
|
|
133
|
+
await dispatchSlackCommand(router, client, payload);
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## Rules for generated code
|
|
137
|
+
|
|
138
|
+
- Verify the signature **before** parsing or dispatching, using `requireSignature` with
|
|
139
|
+
`SLACK_SIGNATURE_POLICY`. The verification needs `ctx.rawBody`, so it must run before anything
|
|
140
|
+
that re-serialises the body.
|
|
141
|
+
- Store `SlackConfig` in `AppConfig` and register the typed section. Never inline `signingSecret` or
|
|
142
|
+
`botToken`.
|
|
143
|
+
- Pass an `IdempotencyStore` to `dispatchEvent`. Slack retries deliveries, so without it a flaky
|
|
144
|
+
handler produces duplicate side effects.
|
|
145
|
+
- Scope idempotency keys by team — `slackEventIdempotencyKey` already does, and a raw `event_id` is
|
|
146
|
+
not unique across workspaces.
|
|
147
|
+
- Handle the `url_verification` challenge. `dispatchSlackEvent` returns it; the native path expects
|
|
148
|
+
you to echo it.
|
|
149
|
+
- Ack within 3 seconds and do slow work in a job. Slack times out and retries.
|
|
150
|
+
- Never log a webhook URL without `redactSlackUrl` — the token is in the path.
|
|
151
|
+
- Import `./comms` functions from `@maroonedsoftware/slack/comms`, never from the root.
|
|
152
|
+
|
|
153
|
+
## Gotchas
|
|
154
|
+
|
|
155
|
+
- **`./comms` normalises only part of the surface.** `dispatchSlackInteraction` handles
|
|
156
|
+
`block_actions` and returns silently for everything else; `view_submission` and `view_closed` stay
|
|
157
|
+
on `SlackInteractionHandlerMap`. Mixing the two paths is expected, not a mistake.
|
|
158
|
+
- **The comms adapter sanitises broadcast sequences.** `<!everyone>`, `<!channel>`, `<!here>` (and
|
|
159
|
+
their `<!channel|label>` forms) in outbound text are rewritten to literal `@everyone` / `@channel`
|
|
160
|
+
/ `@here` so user-supplied text cannot ping a workspace. The **native** `SlackClient` path does
|
|
161
|
+
**not** do this — sanitise yourself there.
|
|
162
|
+
- **The recipient string is overloaded.** `createSlackNotifier` sends to a `response_url` when the
|
|
163
|
+
string starts with `http`, and to a channel id otherwise. A channel id that somehow starts with
|
|
164
|
+
`http` would be misrouted.
|
|
165
|
+
- **Bot messages are filtered out** of the comms path (`bot_id`, `subtype: 'bot_message'`) to avoid
|
|
166
|
+
loops. The native event handlers see them.
|
|
167
|
+
- **`verifySlackSignature` throws; `SlackSignaturePolicy` denies.** Same logic, two shapes.
|
|
168
|
+
- **The signature has a max-age replay window.** Clock skew on your host produces spurious
|
|
169
|
+
verification failures.
|
|
170
|
+
- **`@slack/web-api` is a hard dependency**, unlike `comms` and `cache`. Installing this package
|
|
171
|
+
pulls it in even if you only use the comms path.
|
|
172
|
+
|
|
173
|
+
## Working inside this package
|
|
174
|
+
|
|
175
|
+
```
|
|
176
|
+
src/
|
|
177
|
+
index.ts Root barrel
|
|
178
|
+
slack.config.ts SlackConfig (interface + token)
|
|
179
|
+
slack.error.ts SlackError, IsSlackError
|
|
180
|
+
slack.signature.ts verifySlackSignature + header/constant exports
|
|
181
|
+
slack.signature.policy.ts SlackSignaturePolicy, SLACK_SIGNATURE_POLICY
|
|
182
|
+
slack.event.handler.ts Event handler + map, slackEventIdempotencyKey
|
|
183
|
+
slack.command.handler.ts Command handler + map
|
|
184
|
+
slack.interaction.handler.ts Interaction handler + map, interactionRouteKey
|
|
185
|
+
slack.dispatcher.ts SlackDispatcher
|
|
186
|
+
client/slack.client.ts SlackClient
|
|
187
|
+
client/slack.logger.adapter.ts adaptLogger, redactSlackUrl
|
|
188
|
+
comms.ts Subpath entry — notifier, render, and the three dispatch functions
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Tests are in `tests/`, mirroring `src/`.
|
|
192
|
+
|
|
193
|
+
Invariants a change must not break:
|
|
194
|
+
|
|
195
|
+
- **Nothing reachable from `src/index.ts` may import `@maroonedsoftware/comms` or
|
|
196
|
+
`@maroonedsoftware/cache`.** Both are optional peers; `cache` is imported `type`-only in the
|
|
197
|
+
dispatcher for exactly this reason.
|
|
198
|
+
- No dependency on `@maroonedsoftware/koa`. `SlackSignaturePolicyContext` is structurally
|
|
199
|
+
compatible with koa's context, which is what keeps the arrow out.
|
|
200
|
+
- Signature comparison stays constant-time, and the replay window stays enforced.
|
|
201
|
+
- The comms adapter's broadcast sanitisation is a security control, not formatting.
|
|
202
|
+
- The four chat packages (`slack`, `discord`, `telegram`, `whatsapp`) share a deliberate shape:
|
|
203
|
+
config token, error + guard, verification function + policy, handler maps, dispatcher, client,
|
|
204
|
+
`./comms` adapter. Keep a change consistent across all four.
|
|
205
|
+
|
|
206
|
+
User-visible changes need a changeset in `.changeset/`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@maroonedsoftware/slack",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.5",
|
|
4
4
|
"description": "Slack utilities for ServerKit.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Marooned Software",
|
|
@@ -38,26 +38,27 @@
|
|
|
38
38
|
},
|
|
39
39
|
"license": "MIT",
|
|
40
40
|
"files": [
|
|
41
|
+
"AGENTS.md",
|
|
41
42
|
"dist/**"
|
|
42
43
|
],
|
|
43
44
|
"dependencies": {
|
|
44
45
|
"@slack/web-api": "^7.18.0",
|
|
45
46
|
"injectkit": "^1.6.0",
|
|
46
47
|
"luxon": "^3.7.2",
|
|
47
|
-
"@maroonedsoftware/errors": "1.8.
|
|
48
|
-
"@maroonedsoftware/policies": "0.6.
|
|
49
|
-
"@maroonedsoftware/logger": "1.1.
|
|
48
|
+
"@maroonedsoftware/errors": "1.8.4",
|
|
49
|
+
"@maroonedsoftware/policies": "0.6.4",
|
|
50
|
+
"@maroonedsoftware/logger": "1.1.7"
|
|
50
51
|
},
|
|
51
52
|
"devDependencies": {
|
|
52
53
|
"@types/luxon": "^3.7.2",
|
|
53
|
-
"@maroonedsoftware/
|
|
54
|
-
"@maroonedsoftware/comms": "0.2.3",
|
|
54
|
+
"@maroonedsoftware/comms": "0.2.7",
|
|
55
55
|
"@repo/config-eslint": "0.2.1",
|
|
56
|
+
"@maroonedsoftware/cache": "0.4.4",
|
|
56
57
|
"@repo/config-typescript": "0.1.0"
|
|
57
58
|
},
|
|
58
59
|
"peerDependencies": {
|
|
59
|
-
"@maroonedsoftware/cache": "0.4.
|
|
60
|
-
"@maroonedsoftware/comms": "0.2.
|
|
60
|
+
"@maroonedsoftware/cache": "0.4.4",
|
|
61
|
+
"@maroonedsoftware/comms": "0.2.7"
|
|
61
62
|
},
|
|
62
63
|
"peerDependenciesMeta": {
|
|
63
64
|
"@maroonedsoftware/cache": {
|