Skip to content

Commit 3815e3d

Browse files
authored
Add Slack ephemeral and scheduled messages (#155)
* Add Slack ephemeral and scheduled messages - Store ephemeral messages outside channel history and surface them in the inspector. - Add scheduled message create, list, and delete APIs with SDK coverage. - Update Slack docs, skill examples, and coverage notes for the new chat surface. * Fix Slack scheduled message review issues * Fix Slack ephemeral membership check * Fix Slack scheduled cursor handling * Fix Slack scheduled message review issues * Fix Slack scheduled message formatting
1 parent e6d0241 commit 3815e3d

13 files changed

Lines changed: 615 additions & 13 deletions

File tree

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -603,9 +603,13 @@ Fully stateful Slack Web API emulation with channels, messages, threads, reactio
603603
### Auth & Chat
604604
- `POST /api/auth.test` - test authentication
605605
- `POST /api/chat.postMessage` - post message with text or rich payload fields (supports threads via `thread_ts`)
606+
- `POST /api/chat.postEphemeral` - post ephemeral message outside channel history
606607
- `POST /api/chat.update` - update message text and rich payload fields
607608
- `POST /api/chat.delete` - delete message
608609
- `GET /api/chat.getPermalink` / `POST /api/chat.getPermalink` - get message permalink
610+
- `POST /api/chat.scheduleMessage` - schedule pending message
611+
- `POST /api/chat.deleteScheduledMessage` - delete pending scheduled message
612+
- `POST /api/chat.scheduledMessages.list` - list pending scheduled messages
609613
- `POST /api/chat.meMessage` - /me message
610614

611615
### Conversations

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,13 @@ Fully stateful Slack Web API emulation with channels, messages, threads, reactio
99
## Chat
1010

1111
- `POST /api/chat.postMessage` - post message with text or rich payload fields (supports threads via `thread_ts`)
12+
- `POST /api/chat.postEphemeral` - post ephemeral message outside channel history
1213
- `POST /api/chat.update` - update message text and rich payload fields
1314
- `POST /api/chat.delete` - delete message
1415
- `GET /api/chat.getPermalink` / `POST /api/chat.getPermalink` - get message permalink
16+
- `POST /api/chat.scheduleMessage` - schedule pending message
17+
- `POST /api/chat.deleteScheduledMessage` - delete pending scheduled message
18+
- `POST /api/chat.scheduledMessages.list` - list pending scheduled messages
1519
- `POST /api/chat.meMessage` - /me message
1620

1721
## Conversations

packages/@emulators/slack/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,13 @@ npm install @emulators/slack
1515
### Auth & Chat
1616
- `POST /api/auth.test` — test authentication
1717
- `POST /api/chat.postMessage` — post message with text or rich payload fields (supports threads via `thread_ts`)
18+
- `POST /api/chat.postEphemeral` — post ephemeral message outside channel history
1819
- `POST /api/chat.update` — update message text and rich payload fields
1920
- `POST /api/chat.delete` — delete message
2021
- `GET /api/chat.getPermalink` / `POST /api/chat.getPermalink` — get message permalink
22+
- `POST /api/chat.scheduleMessage` — schedule pending message
23+
- `POST /api/chat.deleteScheduledMessage` — delete pending scheduled message
24+
- `POST /api/chat.scheduledMessages.list` — list pending scheduled messages
2125
- `POST /api/chat.meMessage` — /me message
2226

2327
### Conversations

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ describe("Slack coverage matrix", () => {
4141
it("keeps planned gaps explicit", () => {
4242
const planned = slackCoverageMatrix.filter((entry) => entry.status === "not_started");
4343
expect(planned.map((entry) => entry.method)).toEqual(
44-
expect.arrayContaining(["chat.postEphemeral", "files.getUploadURLExternal", "views.publish"]),
44+
expect.arrayContaining(["files.getUploadURLExternal", "views.publish"]),
4545
);
4646
for (const entry of planned) {
4747
expect(entry.notes).toMatch(/Planned|future/i);

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

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,38 @@ export const slackCoverageMatrix: SlackCoverageEntry[] = [
5151
testedBy: ["slack.test.ts", "slack-sdk.test.ts"],
5252
notes: "Returns deterministic emulator permalinks for top-level messages and threaded replies.",
5353
},
54+
{
55+
family: "chat",
56+
method: "chat.postEphemeral",
57+
route: "POST /api/chat.postEphemeral",
58+
status: "partial",
59+
testedBy: ["slack.test.ts", "slack-sdk.test.ts"],
60+
notes: "Stores ephemeral messages outside channel history and validates target channel membership.",
61+
},
62+
{
63+
family: "chat",
64+
method: "chat.scheduleMessage",
65+
route: "POST /api/chat.scheduleMessage",
66+
status: "partial",
67+
testedBy: ["slack.test.ts", "slack-sdk.test.ts"],
68+
notes: "Stores pending scheduled messages with rich payload fields and validates post_at bounds.",
69+
},
70+
{
71+
family: "chat",
72+
method: "chat.deleteScheduledMessage",
73+
route: "POST /api/chat.deleteScheduledMessage",
74+
status: "partial",
75+
testedBy: ["slack.test.ts", "slack-sdk.test.ts"],
76+
notes: "Deletes pending scheduled messages by channel and scheduled message id.",
77+
},
78+
{
79+
family: "chat",
80+
method: "chat.scheduledMessages.list",
81+
route: "POST /api/chat.scheduledMessages.list",
82+
status: "partial",
83+
testedBy: ["slack.test.ts", "slack-sdk.test.ts"],
84+
notes: "Lists pending scheduled messages with channel, time range, and simple cursor filters.",
85+
},
5486
{
5587
family: "chat",
5688
method: "chat.meMessage",
@@ -227,14 +259,6 @@ export const slackCoverageMatrix: SlackCoverageEntry[] = [
227259
testedBy: ["slack.test.ts"],
228260
notes: "Shows channels and recent messages.",
229261
},
230-
{
231-
family: "chat",
232-
method: "chat.postEphemeral",
233-
route: "POST /api/chat.postEphemeral",
234-
status: "not_started",
235-
testedBy: [],
236-
notes: "Planned for SLACK-3.",
237-
},
238262
{
239263
family: "files",
240264
method: "files.getUploadURLExternal",

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

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,46 @@ describe("Slack plugin - real @slack/web-api WebClient baseline", () => {
110110
expect(message.metadata).toEqual(metadata);
111111
});
112112

113+
it("exercises ephemeral and scheduled messages through the Slack SDK", async () => {
114+
expect(emulator).toBeDefined();
115+
const channel = getSlackStore(emulator!.store).channels.findOneBy("name", "general")!.channel_id;
116+
117+
const ephemeral = await client.chat.postEphemeral({
118+
channel,
119+
user: "U000000001",
120+
text: "ephemeral from WebClient",
121+
});
122+
expect(ephemeral.ok).toBe(true);
123+
expect(ephemeral.message_ts).toBeDefined();
124+
125+
const history = await client.conversations.history({ channel });
126+
expect(history.messages?.some((message) => message.ts === ephemeral.message_ts)).toBe(false);
127+
128+
const postAt = Math.floor(Date.now() / 1000) + 3600;
129+
const scheduled = await client.chat.scheduleMessage({
130+
channel,
131+
text: "scheduled from WebClient",
132+
post_at: postAt,
133+
});
134+
expect(scheduled.ok).toBe(true);
135+
expect(scheduled.scheduled_message_id).toMatch(/^Q/);
136+
137+
const scheduledList = await client.chat.scheduledMessages.list({ channel });
138+
expect(scheduledList.ok).toBe(true);
139+
expect(scheduledList.scheduled_messages?.[0]).toMatchObject({
140+
id: scheduled.scheduled_message_id,
141+
channel_id: channel,
142+
post_at: postAt,
143+
text: "scheduled from WebClient",
144+
});
145+
146+
const deleted = await client.chat.deleteScheduledMessage({
147+
channel,
148+
scheduled_message_id: scheduled.scheduled_message_id!,
149+
});
150+
expect(deleted.ok).toBe(true);
151+
});
152+
113153
it("exercises conversation membership through the Slack SDK", async () => {
114154
const created = await client.conversations.create({ name: "sdk-membership" });
115155
const channel = created.channel!.id!;

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

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -442,6 +442,215 @@ describe("Slack plugin - chat.getPermalink", () => {
442442
});
443443
});
444444

445+
describe("Slack plugin - chat.postEphemeral", () => {
446+
let app: SlackTestApp["app"];
447+
let store: Store;
448+
449+
beforeEach(() => {
450+
({ app, store } = createTestApp());
451+
});
452+
453+
it("stores ephemeral messages outside channel history", async () => {
454+
const ss = getSlackStore(store);
455+
const ch = ss.channels.all()[0];
456+
const blocks = [{ type: "section", text: { type: "plain_text", text: "Only you can see this" } }];
457+
458+
const res = await app.request(`${base}/api/chat.postEphemeral`, {
459+
method: "POST",
460+
headers: authHeaders(),
461+
body: JSON.stringify({ channel: ch.channel_id, user: "U000000001", blocks }),
462+
});
463+
const body = (await res.json()) as any;
464+
expect(body.ok).toBe(true);
465+
expect(body.message_ts).toBeDefined();
466+
467+
const ephemeral = ss.ephemeralMessages.findOneBy("ts", body.message_ts);
468+
expect(ephemeral?.target_user).toBe("U000000001");
469+
expect(ephemeral?.blocks).toEqual(blocks);
470+
471+
const historyRes = await app.request(`${base}/api/conversations.history`, {
472+
method: "POST",
473+
headers: authHeaders(),
474+
body: JSON.stringify({ channel: ch.channel_id }),
475+
});
476+
const history = (await historyRes.json()) as any;
477+
expect(history.messages).toEqual([]);
478+
});
479+
480+
it("accepts channel membership stored by seeded login name", async () => {
481+
const ss = getSlackStore(store);
482+
const ch = ss.channels.all()[0];
483+
ss.channels.update(ch.id, { members: ["admin"], num_members: 1 });
484+
485+
const res = await app.request(`${base}/api/chat.postEphemeral`, {
486+
method: "POST",
487+
headers: authHeaders(),
488+
body: JSON.stringify({ channel: ch.channel_id, user: "U000000001", text: "private" }),
489+
});
490+
const body = (await res.json()) as any;
491+
expect(body.ok).toBe(true);
492+
expect(body.message_ts).toBeDefined();
493+
});
494+
495+
it("returns user_not_in_channel for a target outside the channel", async () => {
496+
const ss = getSlackStore(store);
497+
const ch = ss.channels.all()[0];
498+
ss.users.insert({
499+
user_id: "U000000999",
500+
team_id: "T000000001",
501+
name: "outsider",
502+
real_name: "Outsider",
503+
email: "outsider@emulate.dev",
504+
is_admin: false,
505+
is_bot: false,
506+
deleted: false,
507+
profile: {
508+
display_name: "outsider",
509+
real_name: "Outsider",
510+
email: "outsider@emulate.dev",
511+
image_48: "",
512+
image_192: "",
513+
},
514+
});
515+
516+
const res = await app.request(`${base}/api/chat.postEphemeral`, {
517+
method: "POST",
518+
headers: authHeaders(),
519+
body: JSON.stringify({ channel: ch.channel_id, user: "U000000999", text: "private" }),
520+
});
521+
const body = (await res.json()) as any;
522+
expect(body.ok).toBe(false);
523+
expect(body.error).toBe("user_not_in_channel");
524+
});
525+
});
526+
527+
describe("Slack plugin - scheduled messages", () => {
528+
let app: SlackTestApp["app"];
529+
let store: Store;
530+
531+
beforeEach(() => {
532+
({ app, store } = createTestApp());
533+
});
534+
535+
it("schedules, lists, and deletes a message", async () => {
536+
const ss = getSlackStore(store);
537+
const ch = ss.channels.all()[0];
538+
const postAt = Math.floor(Date.now() / 1000) + 3600;
539+
const blocks = [{ type: "section", text: { type: "plain_text", text: "Scheduled block" } }];
540+
541+
const scheduleRes = await app.request(`${base}/api/chat.scheduleMessage`, {
542+
method: "POST",
543+
headers: authHeaders(),
544+
body: JSON.stringify({
545+
channel: ch.channel_id,
546+
text: "scheduled message",
547+
blocks,
548+
post_at: postAt,
549+
}),
550+
});
551+
const scheduled = (await scheduleRes.json()) as any;
552+
expect(scheduled.ok).toBe(true);
553+
expect(scheduled.channel).toBe(ch.channel_id);
554+
expect(scheduled.scheduled_message_id).toMatch(/^Q/);
555+
expect(scheduled.post_at).toBe(postAt);
556+
expect(scheduled.message).toMatchObject({
557+
type: "delayed_message",
558+
subtype: "bot_message",
559+
text: "scheduled message",
560+
blocks,
561+
});
562+
563+
const listRes = await app.request(`${base}/api/chat.scheduledMessages.list`, {
564+
method: "POST",
565+
headers: authHeaders(),
566+
body: JSON.stringify({ channel: ch.channel_id }),
567+
});
568+
const list = (await listRes.json()) as any;
569+
expect(list.ok).toBe(true);
570+
expect(list.scheduled_messages).toEqual([
571+
expect.objectContaining({
572+
id: scheduled.scheduled_message_id,
573+
channel_id: ch.channel_id,
574+
post_at: postAt,
575+
text: "scheduled message",
576+
}),
577+
]);
578+
579+
const deleteRes = await app.request(`${base}/api/chat.deleteScheduledMessage`, {
580+
method: "POST",
581+
headers: authHeaders(),
582+
body: JSON.stringify({ channel: ch.channel_id, scheduled_message_id: scheduled.scheduled_message_id }),
583+
});
584+
expect(((await deleteRes.json()) as any).ok).toBe(true);
585+
expect(ss.scheduledMessages.all()).toEqual([]);
586+
});
587+
588+
it("returns time_in_past for past scheduled messages", async () => {
589+
const ss = getSlackStore(store);
590+
const ch = ss.channels.all()[0];
591+
592+
const res = await app.request(`${base}/api/chat.scheduleMessage`, {
593+
method: "POST",
594+
headers: authHeaders(),
595+
body: JSON.stringify({
596+
channel: ch.channel_id,
597+
text: "too late",
598+
post_at: Math.floor(Date.now() / 1000) - 1,
599+
}),
600+
});
601+
const body = (await res.json()) as any;
602+
expect(body.ok).toBe(false);
603+
expect(body.error).toBe("time_in_past");
604+
});
605+
606+
it("returns invalid_arguments for nonpositive scheduled list limits", async () => {
607+
const res = await app.request(`${base}/api/chat.scheduledMessages.list`, {
608+
method: "POST",
609+
headers: authHeaders(),
610+
body: JSON.stringify({ limit: -1 }),
611+
});
612+
const body = (await res.json()) as any;
613+
expect(body.ok).toBe(false);
614+
expect(body.error).toBe("invalid_arguments");
615+
});
616+
617+
it("returns invalid_arguments for invalid scheduled list time filters", async () => {
618+
const res = await app.request(`${base}/api/chat.scheduledMessages.list`, {
619+
method: "POST",
620+
headers: authHeaders(),
621+
body: JSON.stringify({ oldest: "not-a-time" }),
622+
});
623+
const body = (await res.json()) as any;
624+
expect(body.ok).toBe(false);
625+
expect(body.error).toBe("invalid_arguments");
626+
});
627+
628+
it("returns invalid_cursor for unknown scheduled message list cursors", async () => {
629+
const res = await app.request(`${base}/api/chat.scheduledMessages.list`, {
630+
method: "POST",
631+
headers: authHeaders(),
632+
body: JSON.stringify({ cursor: "Q000000999" }),
633+
});
634+
const body = (await res.json()) as any;
635+
expect(body.ok).toBe(false);
636+
expect(body.error).toBe("invalid_cursor");
637+
});
638+
639+
it("returns invalid_scheduled_message_id for unknown scheduled messages", async () => {
640+
const ss = getSlackStore(store);
641+
const ch = ss.channels.all()[0];
642+
643+
const res = await app.request(`${base}/api/chat.deleteScheduledMessage`, {
644+
method: "POST",
645+
headers: authHeaders(),
646+
body: JSON.stringify({ channel: ch.channel_id, scheduled_message_id: "Q000000999" }),
647+
});
648+
const body = (await res.json()) as any;
649+
expect(body.ok).toBe(false);
650+
expect(body.error).toBe("invalid_scheduled_message_id");
651+
});
652+
});
653+
445654
describe("Slack plugin - conversations", () => {
446655
let app: SlackTestApp["app"];
447656
let store: Store;

0 commit comments

Comments
 (0)