Skip to content

Commit e6d0241

Browse files
authored
Add Slack message event parity (#154)
* Add Slack message event parity - Add deterministic chat permalinks for posted messages and threaded replies. - Dispatch Slack-shaped message_changed and message_deleted events. - Cover permalink and event flows across route, SDK, and docs. * Add Slack event timestamps * Format Slack chat route * Fix Slack coverage note
1 parent 6de44e1 commit e6d0241

12 files changed

Lines changed: 292 additions & 12 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -605,6 +605,7 @@ Fully stateful Slack Web API emulation with channels, messages, threads, reactio
605605
- `POST /api/chat.postMessage` - post message with text or rich payload fields (supports threads via `thread_ts`)
606606
- `POST /api/chat.update` - update message text and rich payload fields
607607
- `POST /api/chat.delete` - delete message
608+
- `GET /api/chat.getPermalink` / `POST /api/chat.getPermalink` - get message permalink
608609
- `POST /api/chat.meMessage` - /me message
609610

610611
### Conversations

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ Fully stateful Slack Web API emulation with channels, messages, threads, reactio
1111
- `POST /api/chat.postMessage` - post message with text or rich payload fields (supports threads via `thread_ts`)
1212
- `POST /api/chat.update` - update message text and rich payload fields
1313
- `POST /api/chat.delete` - delete message
14+
- `GET /api/chat.getPermalink` / `POST /api/chat.getPermalink` - get message permalink
1415
- `POST /api/chat.meMessage` - /me message
1516

1617
## Conversations
@@ -55,9 +56,11 @@ Fully stateful Slack Web API emulation with channels, messages, threads, reactio
5556

5657
## Event Dispatching
5758

58-
When messages are posted or reactions change, the emulator dispatches `event_callback` payloads to configured webhook URLs matching Slack's Events API format:
59+
When messages are posted, updated, deleted, or reactions change, the emulator dispatches `event_callback` payloads to configured webhook URLs matching Slack's Events API format:
5960

6061
- `message` events on `chat.postMessage`
62+
- `message` with `subtype: message_changed` on `chat.update`
63+
- `message` with `subtype: message_deleted` on `chat.delete`
6164
- rich message fields are included on posted `message` events when present
6265
- `reaction_added` / `reaction_removed` events on `reactions.add` / `reactions.remove`
6366
- `message` with `subtype: bot_message` on incoming webhook posts

packages/@emulators/slack/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ npm install @emulators/slack
1717
- `POST /api/chat.postMessage` — post message with text or rich payload fields (supports threads via `thread_ts`)
1818
- `POST /api/chat.update` — update message text and rich payload fields
1919
- `POST /api/chat.delete` — delete message
20+
- `GET /api/chat.getPermalink` / `POST /api/chat.getPermalink` — get message permalink
2021
- `POST /api/chat.meMessage` — /me message
2122

2223
### Conversations

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ function registeredSlackRoutes(): string[] {
1515
return routes.map((route) => `${route.method} ${route.compiled.pattern}`).sort();
1616
}
1717

18+
function entryRoutes(entry: { route: string | string[] }): string[] {
19+
return Array.isArray(entry.route) ? entry.route : [entry.route];
20+
}
21+
1822
describe("Slack coverage matrix", () => {
1923
it("has unique method entries", () => {
2024
const methods = slackCoverageMatrix.map((entry) => entry.method);
@@ -24,10 +28,12 @@ describe("Slack coverage matrix", () => {
2428
it("maps every registered endpoint to at least one test file", () => {
2529
const currentEntries = slackCoverageMatrix.filter((entry) => entry.status !== "not_started");
2630
expect(currentEntries.length).toBeGreaterThan(0);
27-
expect(currentEntries.map((entry) => entry.route).sort()).toEqual(registeredSlackRoutes());
31+
expect(currentEntries.flatMap(entryRoutes).sort()).toEqual(registeredSlackRoutes());
2832

2933
for (const entry of currentEntries) {
30-
expect(entry.route).toMatch(/^(GET|POST) /);
34+
for (const route of entryRoutes(entry)) {
35+
expect(route).toMatch(/^(GET|POST) /);
36+
}
3137
expect(entry.testedBy.length, entry.method).toBeGreaterThan(0);
3238
}
3339
});

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

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ export type SlackCoverageStatus = "supported" | "partial" | "not_started";
33
export interface SlackCoverageEntry {
44
family: string;
55
method: string;
6-
route: string;
6+
route: string | string[];
77
status: SlackCoverageStatus;
88
testedBy: string[];
99
notes: string;
@@ -25,23 +25,31 @@ export const slackCoverageMatrix: SlackCoverageEntry[] = [
2525
status: "partial",
2626
testedBy: ["slack.test.ts", "slack-sdk.test.ts", "slack-events.test.ts"],
2727
notes:
28-
"Text, thread replies, blocks, attachments, metadata, formatting flags, unfurl flags, and client message ids round trip. DMs, permalinks, and membership checks are future work.",
28+
"Text, thread replies, blocks, attachments, metadata, formatting flags, unfurl flags, and client message ids round trip. DMs and membership checks are future work.",
2929
},
3030
{
3131
family: "chat",
3232
method: "chat.update",
3333
route: "POST /api/chat.update",
3434
status: "partial",
35-
testedBy: ["slack.test.ts", "slack-sdk.test.ts"],
36-
notes: "Stored text and rich message fields are updated. Slack shaped message_changed events are future work.",
35+
testedBy: ["slack.test.ts", "slack-sdk.test.ts", "slack-events.test.ts"],
36+
notes: "Stored text and rich message fields are updated and message_changed events are dispatched.",
3737
},
3838
{
3939
family: "chat",
4040
method: "chat.delete",
4141
route: "POST /api/chat.delete",
4242
status: "partial",
43+
testedBy: ["slack.test.ts", "slack-sdk.test.ts", "slack-events.test.ts"],
44+
notes: "Stored messages are deleted and message_deleted events are dispatched.",
45+
},
46+
{
47+
family: "chat",
48+
method: "chat.getPermalink",
49+
route: ["GET /api/chat.getPermalink", "POST /api/chat.getPermalink"],
50+
status: "partial",
4351
testedBy: ["slack.test.ts", "slack-sdk.test.ts"],
44-
notes: "Stored messages are deleted. Slack shaped message_deleted events are future work.",
52+
notes: "Returns deterministic emulator permalinks for top-level messages and threaded replies.",
4553
},
4654
{
4755
family: "chat",

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

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,95 @@ describe("Slack plugin - event dispatch baseline", () => {
8686
]);
8787
});
8888

89+
it("dispatches message_changed events for chat.update", async () => {
90+
const { app, store, webhooks } = createSlackTestApp();
91+
const capture = captureFetchRequests();
92+
registerSlackEventSubscription(webhooks, ["message"]);
93+
94+
const ch = getSlackStore(store).channels.findOneBy("name", "general")!;
95+
const postRes = await app.request(`${base}/api/chat.postMessage`, {
96+
method: "POST",
97+
headers: authHeaders(),
98+
body: JSON.stringify({ channel: ch.channel_id, text: "before update" }),
99+
});
100+
const posted = (await postRes.json()) as { ts: string };
101+
102+
await app.request(`${base}/api/chat.update`, {
103+
method: "POST",
104+
headers: authHeaders(),
105+
body: JSON.stringify({ channel: ch.channel_id, ts: posted.ts, text: "after update" }),
106+
});
107+
108+
expect(capture.requests).toHaveLength(2);
109+
const event = capture.jsonBodies()[1] as any;
110+
expect(event).toMatchObject({
111+
type: "event_callback",
112+
event: {
113+
type: "message",
114+
subtype: "message_changed",
115+
hidden: true,
116+
channel: ch.channel_id,
117+
message: {
118+
type: "message",
119+
user: "U000000001",
120+
text: "after update",
121+
ts: posted.ts,
122+
edited: { user: "U000000001" },
123+
},
124+
previous_message: {
125+
type: "message",
126+
user: "U000000001",
127+
text: "before update",
128+
ts: posted.ts,
129+
},
130+
},
131+
});
132+
expect(event.event.ts).not.toBe(posted.ts);
133+
expect(event.event.event_ts).toBe(event.event.ts);
134+
expect(event.event.message.edited.ts).toBe(event.event.ts);
135+
});
136+
137+
it("dispatches message_deleted events for chat.delete", async () => {
138+
const { app, store, webhooks } = createSlackTestApp();
139+
const capture = captureFetchRequests();
140+
registerSlackEventSubscription(webhooks, ["message"]);
141+
142+
const ch = getSlackStore(store).channels.findOneBy("name", "general")!;
143+
const postRes = await app.request(`${base}/api/chat.postMessage`, {
144+
method: "POST",
145+
headers: authHeaders(),
146+
body: JSON.stringify({ channel: ch.channel_id, text: "delete event baseline" }),
147+
});
148+
const posted = (await postRes.json()) as { ts: string };
149+
150+
await app.request(`${base}/api/chat.delete`, {
151+
method: "POST",
152+
headers: authHeaders(),
153+
body: JSON.stringify({ channel: ch.channel_id, ts: posted.ts }),
154+
});
155+
156+
expect(capture.requests).toHaveLength(2);
157+
const event = capture.jsonBodies()[1] as any;
158+
expect(event).toMatchObject({
159+
type: "event_callback",
160+
event: {
161+
type: "message",
162+
subtype: "message_deleted",
163+
hidden: true,
164+
channel: ch.channel_id,
165+
deleted_ts: posted.ts,
166+
previous_message: {
167+
type: "message",
168+
user: "U000000001",
169+
text: "delete event baseline",
170+
ts: posted.ts,
171+
},
172+
},
173+
});
174+
expect(event.event.ts).not.toBe(posted.ts);
175+
expect(event.event.event_ts).toBe(event.event.ts);
176+
});
177+
89178
it("dispatches bot message events for incoming webhooks", async () => {
90179
const { app, store, webhooks } = createSlackTestApp();
91180
const capture = captureFetchRequests();

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,12 @@ describe("Slack plugin - real @slack/web-api WebClient baseline", () => {
6868
"reply from WebClient",
6969
]);
7070

71+
const permalink = await client.chat.getPermalink({ channel: channel!, message_ts: reply.ts! });
72+
expect(permalink.ok).toBe(true);
73+
expect(permalink.channel).toBe(channel);
74+
expect(permalink.permalink).toContain(`/archives/${channel}/p${reply.ts!.replace(".", "")}`);
75+
expect(permalink.permalink).toContain(`thread_ts=${posted.ts}`);
76+
7177
const deleted = await client.chat.delete({ channel: channel!, ts: posted.ts! });
7278
expect(deleted.ok).toBe(true);
7379
});

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

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,83 @@ describe("Slack plugin - chat.delete", () => {
365365
});
366366
});
367367

368+
describe("Slack plugin - chat.getPermalink", () => {
369+
let app: SlackTestApp["app"];
370+
let store: Store;
371+
372+
beforeEach(() => {
373+
({ app, store } = createTestApp());
374+
});
375+
376+
it("returns a deterministic permalink for a top-level message", async () => {
377+
const ss = getSlackStore(store);
378+
const ch = ss.channels.all()[0];
379+
380+
const postRes = await app.request(`${base}/api/chat.postMessage`, {
381+
method: "POST",
382+
headers: authHeaders(),
383+
body: JSON.stringify({ channel: ch.channel_id, text: "link me" }),
384+
});
385+
const posted = (await postRes.json()) as any;
386+
387+
const permalinkRes = await app.request(
388+
`${base}/api/chat.getPermalink?channel=${ch.channel_id}&message_ts=${posted.ts}`,
389+
{
390+
method: "GET",
391+
headers: authHeaders(),
392+
},
393+
);
394+
const permalink = (await permalinkRes.json()) as any;
395+
expect(permalink.ok).toBe(true);
396+
expect(permalink.channel).toBe(ch.channel_id);
397+
expect(permalink.permalink).toBe(`${base}/archives/${ch.channel_id}/p${posted.ts.replace(".", "")}`);
398+
});
399+
400+
it("returns threaded reply permalinks from JSON bodies", async () => {
401+
const ss = getSlackStore(store);
402+
const ch = ss.channels.all()[0];
403+
404+
const parentRes = await app.request(`${base}/api/chat.postMessage`, {
405+
method: "POST",
406+
headers: authHeaders(),
407+
body: JSON.stringify({ channel: ch.channel_id, text: "parent" }),
408+
});
409+
const parent = (await parentRes.json()) as any;
410+
411+
const replyRes = await app.request(`${base}/api/chat.postMessage`, {
412+
method: "POST",
413+
headers: authHeaders(),
414+
body: JSON.stringify({ channel: ch.channel_id, text: "reply", thread_ts: parent.ts }),
415+
});
416+
const reply = (await replyRes.json()) as any;
417+
418+
const permalinkRes = await app.request(`${base}/api/chat.getPermalink`, {
419+
method: "POST",
420+
headers: authHeaders(),
421+
body: JSON.stringify({ channel: ch.channel_id, message_ts: reply.ts }),
422+
});
423+
const permalink = (await permalinkRes.json()) as any;
424+
expect(permalink.ok).toBe(true);
425+
expect(permalink.permalink).toBe(
426+
`${base}/archives/${ch.channel_id}/p${reply.ts.replace(".", "")}?thread_ts=${parent.ts}&cid=${ch.channel_id}`,
427+
);
428+
});
429+
430+
it("returns message_not_found for an unknown timestamp", async () => {
431+
const ss = getSlackStore(store);
432+
const ch = ss.channels.all()[0];
433+
434+
const res = await app.request(`${base}/api/chat.getPermalink`, {
435+
method: "POST",
436+
headers: authHeaders(),
437+
body: JSON.stringify({ channel: ch.channel_id, message_ts: "1234567890.000001" }),
438+
});
439+
const body = (await res.json()) as any;
440+
expect(body.ok).toBe(false);
441+
expect(body.error).toBe("message_not_found");
442+
});
443+
});
444+
368445
describe("Slack plugin - conversations", () => {
369446
let app: SlackTestApp["app"];
370447
let store: Store;

packages/@emulators/slack/src/entities.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ export interface SlackMessage extends Entity {
6262
app_id?: string;
6363
client_msg_id?: string;
6464
reply_broadcast?: boolean;
65+
edited?: { user: string; ts: string };
6566
thread_ts?: string;
6667
reply_count: number;
6768
reply_users: string[];

packages/@emulators/slack/src/helpers.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,12 +100,21 @@ export function formatSlackMessage(msg: SlackMessage) {
100100
...(msg.unfurl_links !== undefined ? { unfurl_links: msg.unfurl_links } : {}),
101101
...(msg.unfurl_media !== undefined ? { unfurl_media: msg.unfurl_media } : {}),
102102
...(msg.reply_broadcast !== undefined ? { reply_broadcast: msg.reply_broadcast } : {}),
103+
...(msg.edited ? { edited: msg.edited } : {}),
103104
...(msg.thread_ts ? { thread_ts: msg.thread_ts } : {}),
104105
...(msg.reply_count > 0 ? { reply_count: msg.reply_count, reply_users: msg.reply_users } : {}),
105106
...(msg.reactions.length > 0 ? { reactions: msg.reactions } : {}),
106107
};
107108
}
108109

110+
export function formatSlackPermalink(baseUrl: string, channel: string, msg: SlackMessage): string {
111+
const permalink = `${baseUrl.replace(/\/$/, "")}/archives/${channel}/p${msg.ts.replace(".", "")}`;
112+
if (!msg.thread_ts || msg.thread_ts === msg.ts) return permalink;
113+
114+
const params = new URLSearchParams({ thread_ts: msg.thread_ts, cid: channel });
115+
return `${permalink}?${params.toString()}`;
116+
}
117+
109118
export function parseSlackRichMessageFields(body: Record<string, unknown>): SlackRichMessageParseResult {
110119
const fields: SlackRichMessageFields = {};
111120
const providedFields: SlackRichMessageFieldName[] = [];

0 commit comments

Comments
 (0)