Skip to content

Commit a815676

Browse files
authored
Add Slack conversation lifecycle writes (#156)
* Add Slack conversation lifecycle writes - Implement archive, unarchive, rename, topic, and purpose mutations. - Dispatch lifecycle events and message subtype events for supported writes. - Cover routes, Slack SDK calls, docs, and coverage matrix. * Fix Slack conversation lifecycle review issues * Fix private Slack lifecycle events
1 parent 3815e3d commit a815676

11 files changed

Lines changed: 914 additions & 22 deletions

File tree

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -598,7 +598,7 @@ OAuth 2.0, OpenID Connect, and mutable Google Workspace-style surfaces for local
598598

599599
## Slack API
600600

601-
Fully stateful Slack Web API emulation with channels, messages, threads, reactions, OAuth v2, and incoming webhooks. Chat writes preserve common rich message fields such as `blocks`, `attachments`, `metadata`, formatting flags, unfurl flags, and client message ids.
601+
Fully stateful Slack Web API emulation with channels, messages, threads, reactions, OAuth v2, and incoming webhooks. Chat writes preserve common rich message fields such as `blocks`, `attachments`, `metadata`, formatting flags, unfurl flags, and client message ids. Conversation lifecycle writes update archive state, names, topics, and purposes.
602602

603603
### Auth & Chat
604604
- `POST /api/auth.test` - test authentication
@@ -616,6 +616,9 @@ Fully stateful Slack Web API emulation with channels, messages, threads, reactio
616616
- `POST /api/conversations.list` - list channels (cursor pagination)
617617
- `POST /api/conversations.info` - get channel info
618618
- `POST /api/conversations.create` - create channel
619+
- `POST /api/conversations.archive` / `conversations.unarchive` - archive/restore channel
620+
- `POST /api/conversations.rename` - rename channel
621+
- `POST /api/conversations.setTopic` / `conversations.setPurpose` - update topic/purpose
619622
- `POST /api/conversations.history` - channel history with rich message fields
620623
- `POST /api/conversations.replies` - thread replies with rich message fields
621624
- `POST /api/conversations.join` / `conversations.leave` - join/leave

apps/web/app/docs/slack/page.mdx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Slack API
22

3-
Fully stateful Slack Web API emulation with channels, messages, threads, reactions, OAuth v2, and incoming webhooks. Chat writes preserve common rich message fields such as `blocks`, `attachments`, `metadata`, formatting flags, unfurl flags, and client message ids. State changes dispatch `event_callback` payloads to configured webhook URLs.
3+
Fully stateful Slack Web API emulation with channels, messages, threads, reactions, OAuth v2, and incoming webhooks. Chat writes preserve common rich message fields such as `blocks`, `attachments`, `metadata`, formatting flags, unfurl flags, and client message ids. Conversation lifecycle writes update archive state, names, topics, and purposes. State changes dispatch `event_callback` payloads to configured webhook URLs.
44

55
## Auth
66

@@ -23,6 +23,11 @@ Fully stateful Slack Web API emulation with channels, messages, threads, reactio
2323
- `POST /api/conversations.list` - list channels (cursor pagination)
2424
- `POST /api/conversations.info` - get channel info
2525
- `POST /api/conversations.create` - create channel
26+
- `POST /api/conversations.archive` - archive channel
27+
- `POST /api/conversations.unarchive` - restore archived channel
28+
- `POST /api/conversations.rename` - rename channel
29+
- `POST /api/conversations.setTopic` - update channel topic
30+
- `POST /api/conversations.setPurpose` - update channel purpose
2631
- `POST /api/conversations.history` - channel history with rich message fields (top-level messages)
2732
- `POST /api/conversations.replies` - thread replies with rich message fields
2833
- `POST /api/conversations.join` - join channel
@@ -68,3 +73,7 @@ When messages are posted, updated, deleted, or reactions change, the emulator di
6873
- rich message fields are included on posted `message` events when present
6974
- `reaction_added` / `reaction_removed` events on `reactions.add` / `reactions.remove`
7075
- `message` with `subtype: bot_message` on incoming webhook posts
76+
- `channel_archive` / `channel_unarchive` for public lifecycle archive writes
77+
- `group_archive` / `group_unarchive` for private lifecycle archive writes
78+
- `channel_rename` / `group_rename` and matching name message subtypes on `conversations.rename`
79+
- `message` with public `channel_topic` / `channel_purpose` or private `group_topic` / `group_purpose` subtypes on topic and purpose writes

packages/@emulators/slack/README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# @emulators/slack
22

3-
Fully stateful Slack Web API emulation with channels, messages, threads, reactions, OAuth v2, and incoming webhooks. Chat writes preserve common rich message fields such as `blocks`, `attachments`, `metadata`, formatting flags, unfurl flags, and client message ids.
3+
Fully stateful Slack Web API emulation with channels, messages, threads, reactions, OAuth v2, and incoming webhooks. Chat writes preserve common rich message fields such as `blocks`, `attachments`, `metadata`, formatting flags, unfurl flags, and client message ids. Conversation lifecycle writes update archive state, names, topics, and purposes.
44

55
Part of [emulate](https://github.com/vercel-labs/emulate) — local drop-in replacement services for CI and no-network sandboxes.
66

@@ -28,6 +28,9 @@ npm install @emulators/slack
2828
- `POST /api/conversations.list` — list channels (cursor pagination)
2929
- `POST /api/conversations.info` — get channel info
3030
- `POST /api/conversations.create` — create channel
31+
- `POST /api/conversations.archive` / `conversations.unarchive` — archive/restore channel
32+
- `POST /api/conversations.rename` — rename channel
33+
- `POST /api/conversations.setTopic` / `conversations.setPurpose` — update topic/purpose
3134
- `POST /api/conversations.history` — channel history with rich message fields
3235
- `POST /api/conversations.replies` — thread replies with rich message fields
3336
- `POST /api/conversations.join` / `conversations.leave` — join/leave

packages/@emulators/slack/src/__tests__/slack-coverage.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ export const slackCoverageMatrix: SlackCoverageEntry[] = [
9797
route: "POST /api/conversations.list",
9898
status: "partial",
9999
testedBy: ["slack.test.ts", "slack-sdk.test.ts"],
100-
notes: "Lists non-archived channels with simple cursor pagination. Type filtering is future work.",
100+
notes: "Lists channels with simple cursor pagination and supports exclude_archived. Type filtering is future work.",
101101
},
102102
{
103103
family: "conversations",
@@ -115,6 +115,51 @@ export const slackCoverageMatrix: SlackCoverageEntry[] = [
115115
testedBy: ["slack.test.ts", "slack-sdk.test.ts"],
116116
notes: "Creates public or private channels and rejects duplicate names.",
117117
},
118+
{
119+
family: "conversations",
120+
method: "conversations.archive",
121+
route: "POST /api/conversations.archive",
122+
status: "partial",
123+
testedBy: ["slack.test.ts", "slack-sdk.test.ts", "slack-events.test.ts"],
124+
notes:
125+
"Archives channels, excludes them from list results, and dispatches channel_archive or group_archive lifecycle events.",
126+
},
127+
{
128+
family: "conversations",
129+
method: "conversations.unarchive",
130+
route: "POST /api/conversations.unarchive",
131+
status: "partial",
132+
testedBy: ["slack.test.ts", "slack-sdk.test.ts", "slack-events.test.ts"],
133+
notes:
134+
"Unarchives channels, adds the caller back to membership when needed, and dispatches channel_unarchive or group_unarchive lifecycle events.",
135+
},
136+
{
137+
family: "conversations",
138+
method: "conversations.rename",
139+
route: "POST /api/conversations.rename",
140+
status: "partial",
141+
testedBy: ["slack.test.ts", "slack-sdk.test.ts", "slack-events.test.ts"],
142+
notes:
143+
"Renames member-visible channels with basic Slack name validation and dispatches channel_rename or group_rename events.",
144+
},
145+
{
146+
family: "conversations",
147+
method: "conversations.setTopic",
148+
route: "POST /api/conversations.setTopic",
149+
status: "partial",
150+
testedBy: ["slack.test.ts", "slack-sdk.test.ts", "slack-events.test.ts"],
151+
notes:
152+
"Updates channel topic metadata, validates membership and length, and dispatches channel_topic or group_topic messages.",
153+
},
154+
{
155+
family: "conversations",
156+
method: "conversations.setPurpose",
157+
route: "POST /api/conversations.setPurpose",
158+
status: "partial",
159+
testedBy: ["slack.test.ts", "slack-sdk.test.ts", "slack-events.test.ts"],
160+
notes:
161+
"Updates channel purpose metadata, validates membership and length, and dispatches channel_purpose or group_purpose messages.",
162+
},
118163
{
119164
family: "conversations",
120165
method: "conversations.history",

packages/@emulators/slack/src/__tests__/slack-events.test.ts

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,4 +201,258 @@ describe("Slack plugin - event dispatch baseline", () => {
201201
});
202202
expect((capture.jsonBodies()[0] as any).event.user).toBeUndefined();
203203
});
204+
205+
it("dispatches archive and unarchive lifecycle events", async () => {
206+
const { app, webhooks } = createSlackTestApp();
207+
const capture = captureFetchRequests();
208+
registerSlackEventSubscription(webhooks, ["channel_archive", "channel_unarchive", "message"]);
209+
210+
const createRes = await app.request(`${base}/api/conversations.create`, {
211+
method: "POST",
212+
headers: authHeaders(),
213+
body: JSON.stringify({ name: "event-archive-test" }),
214+
});
215+
const created = (await createRes.json()) as any;
216+
const channel = created.channel.id;
217+
218+
await app.request(`${base}/api/conversations.archive`, {
219+
method: "POST",
220+
headers: authHeaders(),
221+
body: JSON.stringify({ channel }),
222+
});
223+
await app.request(`${base}/api/conversations.unarchive`, {
224+
method: "POST",
225+
headers: authHeaders(),
226+
body: JSON.stringify({ channel }),
227+
});
228+
229+
expect(capture.requests).toHaveLength(4);
230+
expect(capture.jsonBodies()).toEqual([
231+
expect.objectContaining({
232+
event: expect.objectContaining({
233+
type: "channel_archive",
234+
channel,
235+
user: "U000000001",
236+
}),
237+
}),
238+
expect.objectContaining({
239+
event: expect.objectContaining({
240+
type: "message",
241+
subtype: "channel_archive",
242+
channel,
243+
user: "U000000001",
244+
}),
245+
}),
246+
expect.objectContaining({
247+
event: expect.objectContaining({
248+
type: "channel_unarchive",
249+
channel,
250+
user: "U000000001",
251+
}),
252+
}),
253+
expect.objectContaining({
254+
event: expect.objectContaining({
255+
type: "message",
256+
subtype: "channel_unarchive",
257+
channel,
258+
user: "U000000001",
259+
}),
260+
}),
261+
]);
262+
});
263+
264+
it("dispatches private archive and unarchive lifecycle events as group events", async () => {
265+
const { app, webhooks } = createSlackTestApp();
266+
const capture = captureFetchRequests();
267+
registerSlackEventSubscription(webhooks, ["group_archive", "group_unarchive", "message"]);
268+
269+
const createRes = await app.request(`${base}/api/conversations.create`, {
270+
method: "POST",
271+
headers: authHeaders(),
272+
body: JSON.stringify({ name: "event-private-archive-test", is_private: true }),
273+
});
274+
const created = (await createRes.json()) as any;
275+
const channel = created.channel.id;
276+
277+
await app.request(`${base}/api/conversations.archive`, {
278+
method: "POST",
279+
headers: authHeaders(),
280+
body: JSON.stringify({ channel }),
281+
});
282+
await app.request(`${base}/api/conversations.unarchive`, {
283+
method: "POST",
284+
headers: authHeaders(),
285+
body: JSON.stringify({ channel }),
286+
});
287+
288+
expect(capture.requests).toHaveLength(4);
289+
expect(capture.jsonBodies()).toEqual([
290+
expect.objectContaining({
291+
event: expect.objectContaining({
292+
type: "group_archive",
293+
channel,
294+
user: "U000000001",
295+
}),
296+
}),
297+
expect.objectContaining({
298+
event: expect.objectContaining({
299+
type: "message",
300+
subtype: "group_archive",
301+
channel,
302+
user: "U000000001",
303+
}),
304+
}),
305+
expect.objectContaining({
306+
event: expect.objectContaining({
307+
type: "group_unarchive",
308+
channel,
309+
user: "U000000001",
310+
}),
311+
}),
312+
expect.objectContaining({
313+
event: expect.objectContaining({
314+
type: "message",
315+
subtype: "group_unarchive",
316+
channel,
317+
user: "U000000001",
318+
}),
319+
}),
320+
]);
321+
});
322+
323+
it("dispatches rename, topic, and purpose lifecycle events", async () => {
324+
const { app, webhooks } = createSlackTestApp();
325+
const capture = captureFetchRequests();
326+
registerSlackEventSubscription(webhooks, ["channel_rename", "message"]);
327+
328+
const createRes = await app.request(`${base}/api/conversations.create`, {
329+
method: "POST",
330+
headers: authHeaders(),
331+
body: JSON.stringify({ name: "event-lifecycle-test" }),
332+
});
333+
const created = (await createRes.json()) as any;
334+
const channel = created.channel.id;
335+
336+
await app.request(`${base}/api/conversations.rename`, {
337+
method: "POST",
338+
headers: authHeaders(),
339+
body: JSON.stringify({ channel, name: "event-renamed-test" }),
340+
});
341+
await app.request(`${base}/api/conversations.setTopic`, {
342+
method: "POST",
343+
headers: authHeaders(),
344+
body: JSON.stringify({ channel, topic: "Event topic" }),
345+
});
346+
await app.request(`${base}/api/conversations.setPurpose`, {
347+
method: "POST",
348+
headers: authHeaders(),
349+
body: JSON.stringify({ channel, purpose: "Event purpose" }),
350+
});
351+
352+
expect(capture.requests).toHaveLength(4);
353+
expect(capture.jsonBodies()).toEqual([
354+
expect.objectContaining({
355+
event: expect.objectContaining({
356+
type: "channel_rename",
357+
channel: expect.objectContaining({
358+
id: channel,
359+
name: "event-renamed-test",
360+
}),
361+
}),
362+
}),
363+
expect.objectContaining({
364+
event: expect.objectContaining({
365+
type: "message",
366+
subtype: "channel_name",
367+
channel,
368+
old_name: "event-lifecycle-test",
369+
name: "event-renamed-test",
370+
}),
371+
}),
372+
expect.objectContaining({
373+
event: expect.objectContaining({
374+
type: "message",
375+
subtype: "channel_topic",
376+
channel,
377+
topic: "Event topic",
378+
}),
379+
}),
380+
expect.objectContaining({
381+
event: expect.objectContaining({
382+
type: "message",
383+
subtype: "channel_purpose",
384+
channel,
385+
purpose: "Event purpose",
386+
}),
387+
}),
388+
]);
389+
});
390+
391+
it("dispatches private rename, topic, and purpose lifecycle events as group events", async () => {
392+
const { app, webhooks } = createSlackTestApp();
393+
const capture = captureFetchRequests();
394+
registerSlackEventSubscription(webhooks, ["group_rename", "message"]);
395+
396+
const createRes = await app.request(`${base}/api/conversations.create`, {
397+
method: "POST",
398+
headers: authHeaders(),
399+
body: JSON.stringify({ name: "event-private-lifecycle-test", is_private: true }),
400+
});
401+
const created = (await createRes.json()) as any;
402+
const channel = created.channel.id;
403+
404+
await app.request(`${base}/api/conversations.rename`, {
405+
method: "POST",
406+
headers: authHeaders(),
407+
body: JSON.stringify({ channel, name: "event-private-renamed-test" }),
408+
});
409+
await app.request(`${base}/api/conversations.setTopic`, {
410+
method: "POST",
411+
headers: authHeaders(),
412+
body: JSON.stringify({ channel, topic: "Private event topic" }),
413+
});
414+
await app.request(`${base}/api/conversations.setPurpose`, {
415+
method: "POST",
416+
headers: authHeaders(),
417+
body: JSON.stringify({ channel, purpose: "Private event purpose" }),
418+
});
419+
420+
expect(capture.requests).toHaveLength(4);
421+
expect(capture.jsonBodies()).toEqual([
422+
expect.objectContaining({
423+
event: expect.objectContaining({
424+
type: "group_rename",
425+
channel: expect.objectContaining({
426+
id: channel,
427+
name: "event-private-renamed-test",
428+
}),
429+
}),
430+
}),
431+
expect.objectContaining({
432+
event: expect.objectContaining({
433+
type: "message",
434+
subtype: "group_name",
435+
channel,
436+
old_name: "event-private-lifecycle-test",
437+
name: "event-private-renamed-test",
438+
}),
439+
}),
440+
expect.objectContaining({
441+
event: expect.objectContaining({
442+
type: "message",
443+
subtype: "group_topic",
444+
channel,
445+
topic: "Private event topic",
446+
}),
447+
}),
448+
expect.objectContaining({
449+
event: expect.objectContaining({
450+
type: "message",
451+
subtype: "group_purpose",
452+
channel,
453+
purpose: "Private event purpose",
454+
}),
455+
}),
456+
]);
457+
});
204458
});

0 commit comments

Comments
 (0)