Skip to content

Commit 49fa892

Browse files
deepunykclaude
andcommitted
Simulate Slack events locally, signed, without a workspace
Testing a Slack app means running a tunnel and clicking around a sandbox workspace until it emits the event you need. That breaks down for the events Slack will not produce on demand: a message dated 400 days ago, a tombstone, a retry, an installation you do not have. slack-sim builds those payloads and signs them the way Slack does, so they hit a real endpoint through real verification middleware. - event builders for messages, edits, deletes, tombstones, bot messages, file shares, reactions, membership and channel lifecycle - interactivity and slash commands, form-encoded as Slack actually sends them - v0 request signing, retry headers, stale-timestamp signing, ack-budget timing - seeded ids, so a fixture is byte-identical on every machine - YAML scenarios for multi-step flows, exiting non-zero for CI - record -> redact -> replay: turn a captured payload into a committable fixture with ids consistently remapped and timestamps re-anchored Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
0 parents  commit 49fa892

28 files changed

Lines changed: 4266 additions & 0 deletions

.github/workflows/ci.yml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
test:
11+
runs-on: ubuntu-latest
12+
strategy:
13+
matrix:
14+
node-version: [20, 22]
15+
steps:
16+
- uses: actions/checkout@v4
17+
- uses: actions/setup-node@v4
18+
with:
19+
node-version: ${{ matrix.node-version }}
20+
cache: npm
21+
- run: npm ci
22+
- run: npm run typecheck
23+
- run: npm test
24+
- run: npm run build

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
node_modules/
2+
dist/
3+
.omc/
4+
*.log
5+
.env
6+
.DS_Store

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 Deepak Nayak
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
# slack-sim
2+
3+
Simulate Slack events locally. Signed payloads, no workspace, no tunnel.
4+
5+
[![CI](https://github.com/deepunyk/slack-sim/actions/workflows/ci.yml/badge.svg)](https://github.com/deepunyk/slack-sim/actions/workflows/ci.yml)
6+
[![MIT License](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
7+
8+
```bash
9+
npx slack-sim send message --text "hello" --url http://localhost:3000/slack/events
10+
```
11+
12+
## The problem
13+
14+
To test a Slack app you run ngrok, install into a sandbox workspace, and then click things in Slack until it produces the event you need.
15+
16+
That works right up until you need an event Slack will not produce on demand:
17+
18+
- **A message from 400 days ago.** Slack will not let you post one. So retention jobs, SLA timers, stale-thread reminders and purge sweeps are untestable.
19+
- **A tombstone.** What a thread parent becomes when deleted with replies still attached. Arrives as `message_changed` with no text.
20+
- **A retry.** Slack retries any non-2xx up to 3 times with `x-slack-retry-num`. Your idempotency handling has probably never run.
21+
- **A workspace you do not have.** Enterprise Grid, a shared channel, a second team id.
22+
- **CI.** No tunnel, no workspace, no clicking.
23+
24+
`slack-sim` builds those payloads and signs them the way Slack does, so they hit your real endpoint through your real verification middleware.
25+
26+
## Install
27+
28+
```bash
29+
npm install --save-dev slack-sim # or: npx slack-sim
30+
```
31+
32+
Node 18+. One dependency (`yaml`).
33+
34+
## Use it from the CLI
35+
36+
Print a payload:
37+
38+
```bash
39+
slack-sim send message --pretty
40+
```
41+
42+
Send it to your app:
43+
44+
```bash
45+
export SLACK_SIGNING_SECRET=your-secret
46+
slack-sim send app_mention --text "deploy prod" --url http://localhost:3000/slack/events
47+
```
48+
49+
```
50+
ok 200 14ms
51+
```
52+
53+
The events that are hard to get any other way:
54+
55+
```bash
56+
# A message from 400 days ago
57+
slack-sim send message --age-days 400 --url $URL
58+
59+
# An edit — outer ts is the edit time, inner message.ts is the original id
60+
slack-sim send message_changed --text "after" --previous-text "before" --url $URL
61+
62+
# A deleted thread parent
63+
slack-sim send tombstone --url $URL
64+
65+
# Slack's 2nd retry of an event you already processed
66+
slack-sim send message --retry 2 --retry-reason http_timeout --url $URL
67+
68+
# Another app posting: bot_id set, no user field. The classic echo-loop bug.
69+
slack-sim send bot_message --url $URL
70+
71+
# A signature 10 minutes stale — a correct verifier must reject this
72+
slack-sim send message --old-timestamp --url $URL
73+
```
74+
75+
Interactivity and slash commands are form-encoded, not JSON. That difference is where handlers quietly break, and `slack-sim` encodes them the way Slack really does:
76+
77+
```bash
78+
slack-sim send block_actions --action-id approve_button --url $URL/slack/interactive
79+
slack-sim send view_submission --callback-id ticket_modal --url $URL/slack/interactive
80+
slack-sim send command --command /deploy --text "prod" --url $URL/slack/commands
81+
```
82+
83+
`slack-sim list` prints everything it can build.
84+
85+
### Deterministic workspaces
86+
87+
Pass a seed and you get the same team, channel and user ids on every run, on every machine. That is what makes committed fixtures diffable.
88+
89+
```bash
90+
slack-sim send message --seed acme-corp # same ids forever
91+
slack-sim send message --team T123 --channel C456 --user U789
92+
```
93+
94+
## Use it from tests
95+
96+
`signedRequest` gives you the exact bytes and headers Slack would send, so you can drive your handler in-process. No network, no port.
97+
98+
```ts
99+
import { createContext, message, signedRequest } from 'slack-sim';
100+
101+
const ctx = createContext({ seed: 'test' });
102+
103+
it('ignores messages older than a year', async () => {
104+
const request = signedRequest(message(ctx, { ageDays: 400 }), {
105+
signingSecret: process.env.SLACK_SIGNING_SECRET
106+
});
107+
108+
const response = await app.inject({
109+
method: 'POST',
110+
url: '/slack/events',
111+
headers: request.headers,
112+
payload: request.body // send this verbatim — re-serialising breaks the signature
113+
});
114+
115+
expect(response.statusCode).toBe(200);
116+
expect(archiveJob).not.toHaveBeenCalled();
117+
});
118+
```
119+
120+
Or go over the wire with `deliver`, which reports elapsed time against Slack's 3-second ack budget:
121+
122+
```ts
123+
import { deliver, SLACK_ACK_BUDGET_MS } from 'slack-sim';
124+
125+
const result = await deliver('http://localhost:3000/slack/events', message(ctx));
126+
expect(result.durationMs).toBeLessThan(SLACK_ACK_BUDGET_MS); // else Slack retries in prod
127+
```
128+
129+
`verifySignature` is exported too, so you can unit-test your own verifier against the same bytes it will see in production.
130+
131+
## Scenarios
132+
133+
The flows worth testing are never one event. Post, reply, react, edit, delete — reproducing that by hand takes minutes and is never quite the same twice.
134+
135+
```yaml
136+
# support-thread.yml
137+
context:
138+
seed: support-flow
139+
140+
steps:
141+
- event: message
142+
text: "the printer is on fire again"
143+
as: parent # remember this message's ts
144+
145+
- event: message
146+
text: "looking into it"
147+
threadTs: $parent # reply in the thread
148+
expect: 200
149+
150+
- event: reaction_added
151+
ts: $parent
152+
emoji: eyes
153+
154+
- event: message_changed
155+
ts: $parent
156+
text: "the printer is fine now"
157+
previousText: "the printer is on fire again"
158+
159+
- event: message_deleted
160+
ts: $parent
161+
```
162+
163+
```bash
164+
slack-sim scenario support-thread.yml --url http://localhost:3000/slack/events
165+
```
166+
167+
```
168+
ok 1. message → 200 12ms
169+
ok 2. message → 200 (expected 200) 9ms
170+
ok 3. reaction_added → 200 7ms
171+
ok 4. message_changed → 200 11ms
172+
ok 5. message_deleted → 200 8ms
173+
174+
5/5 steps passed
175+
```
176+
177+
Exits non-zero on failure, so it drops straight into CI. More in [`examples/`](examples).
178+
179+
## Record, redact, replay
180+
181+
Generated payloads are close. Payloads your workspace actually produced are exact — but you cannot commit those, because they carry real user ids, channel names, message text and tokens.
182+
183+
```bash
184+
# capture a real payload however you like (a log line, a debug endpoint), then:
185+
cat captured.json | slack-sim redact --seed acme --age-days 400 > test/fixtures/stale.json
186+
slack-sim replay test/fixtures/stale.json --url $URL
187+
```
188+
189+
`redact` swaps every Slack id for a stable fake, so the same real id always becomes the same fake id and the payload stays internally consistent: a `thread_ts` still matches its parent's `ts` afterwards. Tokens, emails and workspace URLs are stripped. Message text is replaced unless you pass `--keep-text`.
190+
191+
`--age-days` re-anchors the timestamps so the fixture reads as that old at replay time, preserving the gaps between messages. Without it a committed fixture ages a day per day and any age-sensitive branch eventually flips on its own.
192+
193+
## What it builds
194+
195+
| | |
196+
|---|---|
197+
| **Messages** | `message`, `message_changed`, `message_deleted`, `tombstone`, `bot_message`, `file_share`, threaded replies, `thread_broadcast` |
198+
| **Events** | `app_mention`, `reaction_added`, `reaction_removed`, `member_joined_channel`, `member_left_channel`, `channel_archive`, `channel_unarchive`, `channel_rename`, `channel_deleted`, `app_home_opened` |
199+
| **Interactivity** | `block_actions`, `view_submission`, `view_closed`, `message_action` |
200+
| **Other** | slash commands, `url_verification`, `app_uninstalled`, `tokens_revoked` |
201+
| **Delivery** | v0 request signing, retry headers, stale-timestamp signing, ack-budget timing |
202+
203+
Every payload carries the fields that are easy to omit by hand and load-bearing in practice: `authorizations` (Bolt uses it to resolve the installation), `event_context`, `channel_type`, `client_msg_id`.
204+
205+
## What it is not
206+
207+
Not a Slack Web API mock. `slack-sim` sends events *to* your app; it does not answer the `chat.postMessage` calls your app makes back. Pair it with `nock` or [`@slack/web-api`'s](https://github.com/slackapi/node-slack-sdk) own test helpers for the outbound half.
208+
209+
Not a replacement for a sandbox workspace. Use a real workspace to discover what a payload looks like. Use `slack-sim` to replay it a thousand times, backdated, in CI.
210+
211+
## Contributing
212+
213+
Payload shapes drift, and the ones here come from real traffic. If you have a payload `slack-sim` gets wrong, open an issue with a redacted sample (`slack-sim redact` will do it) and it will get fixed.
214+
215+
```bash
216+
npm install && npm test
217+
```
218+
219+
MIT.

examples/stale-message.yml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# A message that is 400 days old, then edited. This is impossible to produce against a real
2+
# Slack workspace: Slack will not let you post a message backdated into the past, so any code
3+
# that depends on message age (retention jobs, SLA windows, "stale thread" reminders, purge
4+
# sweeps) is normally untestable. `ageDays` is the one flag that makes it testable.
5+
#
6+
# Run with:
7+
# slack-sim scenario examples/stale-message.yml --url http://localhost:3000/slack/events
8+
context:
9+
seed: stale-message-example
10+
11+
steps:
12+
- event: message
13+
as: stale
14+
ageDays: 400
15+
text: "this message is 400 days old the moment it arrives"
16+
17+
- event: message_changed
18+
ts: $stale
19+
previousText: "this message is 400 days old the moment it arrives"
20+
text: "this message is 400 days old the moment it arrives (edited)"

examples/support-thread.yml

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# A realistic support-thread flow: someone posts, gets a reply, a teammate reacts, then the
2+
# original message is edited and later deleted. Run with:
3+
# slack-sim scenario examples/support-thread.yml --url http://localhost:3000/slack/events
4+
#
5+
# `context.seed` pins the workspace (team/channel/user ids) so this fixture is byte-identical
6+
# across runs, which matters if you diff recorded output in CI.
7+
context:
8+
seed: support-thread-example
9+
10+
steps:
11+
# 1. The customer opens a thread. `as: parent` remembers this message's ts so later steps can
12+
# thread onto it via `$parent`.
13+
- event: message
14+
as: parent
15+
text: "Hey, I can't log in after the last update. Anyone else seeing this?"
16+
17+
# 2. A support agent replies inside the thread. Exercises thread_ts resolution via $ref.
18+
- event: message
19+
threadTs: $parent
20+
text: "Thanks for flagging — can you tell me which browser you're on?"
21+
22+
# 3. Someone reacts to the original message, e.g. to acknowledge it without replying.
23+
- event: reaction_added
24+
ts: $parent
25+
emoji: eyes
26+
27+
# 4. The customer edits their original message (typo fix, more detail, whatever). This is the
28+
# payload shape that trips up naive handlers: the outer `ts` is the edit time, and the
29+
# original message id only survives inside `event.message.ts`.
30+
- event: message_changed
31+
ts: $parent
32+
previousText: "Hey, I cant log in after the last update. Anyone else seeing this?"
33+
text: "Hey, I can't log in after the last update (Chrome, macOS). Anyone else seeing this?"
34+
35+
# 5. The customer deletes their message once it's resolved. Handlers that only track messages
36+
# by outer ts, or that assume deletes always have text, get exercised here too.
37+
- event: message_deleted
38+
ts: $parent
39+
previousText: "Hey, I can't log in after the last update (Chrome, macOS). Anyone else seeing this?"

0 commit comments

Comments
 (0)