diff --git a/README.md b/README.md index 07909579..394b5f7d 100644 --- a/README.md +++ b/README.md @@ -1165,7 +1165,11 @@ Uninstall dispatches `app_uninstalled` and `tokens_revoked` to matching event su ### Inspector - `GET /` - tabbed local inspector for conversations, messages, files, views, auth records, incoming webhooks, event subscriptions, and event deliveries -When a supported Slack write emits an `event_callback`, the payload contains the inner `event` plus outer `team_id`, `event_id`, and `event_time`. The team comes from the presented Slack token's installation; development tokens without a stored Slack record fall back to the affected channel, user, or file's team, then the seeded workspace team (or `T000000001`). Incoming webhook posts use their webhook record's team, or the target channel's team when no record matches. `event_time` is an integer Unix timestamp in seconds. Each logical event gets a new `event_id`, shared across deliveries to multiple subscribers. +When a supported Slack write emits an `event_callback`, the payload contains the inner `event` plus outer `token`, `api_app_id`, `authorizations`, `team_id`, `event_id`, and `event_time`. The team comes from the presented Slack token's installation; development tokens without a stored Slack record fall back to the affected channel, user, or file's team, then the seeded workspace team (or `T000000001`). Incoming webhook posts use their webhook record's team, or the target channel's team when no record matches. `event_time` is an integer Unix timestamp in seconds. Each logical event gets a new `event_id`, shared across deliveries to multiple subscribers. + +Callbacks identify each workspace installation with its recipient `api_app_id` and bot authorization. With multiple installed apps, each receives a separate envelope; configured webhook subscriptions are shared and can route by `api_app_id`. Without an installation, callbacks use `A000000001` and a local user authorization. The legacy `token` field defaults to `emulate-verification-token`; set `slack.verification_token` to match receivers that check it. Use `slack.signing_secret` for request signature verification. + +Every inner event includes `event_ts`. Message events include `channel_type` (`channel`, `group`, `im`, or `mpim`). Posts made with an issued or seeded bot token include the bot's `bot_id`, `app_id`, and `bot_profile` when the corresponding bot record exists. These fields persist in history, replies, and updates so handlers can identify their own messages. Removing an installed bot with `conversations.kick` or `conversations.leave` also emits `channel_left` or `group_left`, with `actor_id` and `event_ts`, for the affected app. To test bot handlers, invite an installed app's `bot_user_id` with `conversations.invite`, subscribe to `app_mention`, then post text containing `<@BOT_USER_ID>` with `chat.postMessage`. Channel mentions produce one callback per mentioned app, with outer `api_app_id`, inner `event_ts`, and the original message and thread identity. DMs and MPIMs use `message` events. diff --git a/apps/web/content/docs/slack.mdx b/apps/web/content/docs/slack.mdx index b0643506..b4bbbe31 100644 --- a/apps/web/content/docs/slack.mdx +++ b/apps/web/content/docs/slack.mdx @@ -150,7 +150,11 @@ Uninstall dispatches `app_uninstalled` and `tokens_revoked` to matching event su For supported Slack writes that emit events, the emulator dispatches `event_callback` payloads to configured webhook URLs matching Slack's Events API format: -Each callback includes outer `team_id`, `event_id`, and integer Unix-seconds `event_time` alongside the inner `event`. For authenticated Web API writes, `team_id` comes from the presented Slack token's installation. Development tokens without a stored Slack record fall back to the affected channel, user, or file's team, then the seeded workspace team (or `T000000001`). Incoming webhook posts use their webhook record's team, or the target channel's team when no record matches. Each logical event gets a distinct `event_id` shared by deliveries to multiple subscribers. +Each callback includes outer `token`, `api_app_id`, `authorizations`, `team_id`, `event_id`, and integer Unix-seconds `event_time` alongside the inner `event`. For authenticated Web API writes, `team_id` comes from the presented Slack token's installation. Development tokens without a stored Slack record fall back to the affected channel, user, or file's team, then the seeded workspace team (or `T000000001`). Incoming webhook posts use their webhook record's team, or the target channel's team when no record matches. Each logical event gets a distinct `event_id` shared by deliveries to multiple subscribers. + +Callbacks identify each workspace installation with its recipient `api_app_id` and bot authorization. With multiple installed apps, each receives a separate envelope; configured webhook subscriptions are shared and can route by `api_app_id`. Without an installation, callbacks use `A000000001` and a local user authorization. The legacy `token` field defaults to `emulate-verification-token`; set `slack.verification_token` to match receivers that check it. Use `slack.signing_secret` for request signature verification. + +Every inner event includes `event_ts`. Message events include `channel_type` (`channel`, `group`, `im`, or `mpim`). Posts made with an issued or seeded bot token include the bot's `bot_id`, `app_id`, and `bot_profile` when the corresponding bot record exists. These fields persist in history, replies, and updates so handlers can identify their own messages. Removing an installed bot with `conversations.kick` or `conversations.leave` also emits `channel_left` or `group_left`, with `actor_id` and `event_ts`, for the affected app. To test bot handlers, invite an installed app's `bot_user_id` with `conversations.invite`, subscribe to `app_mention`, then post text containing `<@BOT_USER_ID>` with `chat.postMessage`. Channel mentions produce one callback per mentioned app, with outer `api_app_id`, inner `event_ts`, and the original message and thread identity. DMs and MPIMs use `message` events. @@ -168,6 +172,7 @@ To test bot handlers, invite an installed app's `bot_user_id` with `conversation - `channel_rename` / `group_rename` and matching name message subtypes on `conversations.rename` - `message` with public `channel_topic` / `channel_purpose` or private `group_topic` / `group_purpose` subtypes on topic and purpose writes - `member_joined_channel` / `member_left_channel` on invite, join, leave, and kick writes +- `channel_left` / `group_left` when an installed bot leaves or is removed - `im_created`, `im_open`, `im_close`, `im_marked`, and group open/close/marked events for DM and MPIM writes - `user_change` on profile writes - `presence_change` on presence writes diff --git a/packages/@emulators/slack/README.md b/packages/@emulators/slack/README.md index 9324eca6..8ecbe457 100644 --- a/packages/@emulators/slack/README.md +++ b/packages/@emulators/slack/README.md @@ -160,7 +160,11 @@ A modal receiver must acknowledge within three seconds. An empty HTTP 200 closes ### Inspector - `GET /` — tabbed local inspector for conversations, messages, files, views, auth records, incoming webhooks, event subscriptions, and event deliveries -When a supported write emits an `event_callback`, the payload includes the inner `event` and outer `team_id`, `event_id`, and integer Unix-seconds `event_time`. The team comes from the presented Slack token's installation; development tokens without a stored Slack record fall back to the affected channel, user, or file's team, then the seeded workspace team (or `T000000001`). Incoming webhook posts use their webhook record's team, or the target channel's team when no record matches. Each logical event has a distinct `event_id` shared by deliveries to multiple subscribers. +When a supported write emits an `event_callback`, the payload includes the inner `event` and outer `token`, `api_app_id`, `authorizations`, `team_id`, `event_id`, and integer Unix-seconds `event_time`. The team comes from the presented Slack token's installation; development tokens without a stored Slack record fall back to the affected channel, user, or file's team, then the seeded workspace team (or `T000000001`). Incoming webhook posts use their webhook record's team, or the target channel's team when no record matches. Each logical event has a distinct `event_id` shared by deliveries to multiple subscribers. + +Each workspace installation receives an envelope attributed to its `api_app_id` and bot authorization. Event subscriptions are shared across apps; receivers can route by `api_app_id`. Without an installation, callbacks use `A000000001` and a local user authorization. The legacy `token` defaults to `emulate-verification-token`; override it with `slack.verification_token`. Use `slack.signing_secret` for signature verification. + +Inner events include `event_ts`. Message events identify `channel`, `group`, `im`, or `mpim` in `channel_type`. Posts made with issued or seeded bot tokens and incoming webhook or interaction replies include `bot_id`, `app_id`, and `bot_profile` when the bot record exists, preserving bot identity in callbacks, updates, history, and replies. Removing an installed bot with `conversations.kick` or `conversations.leave` also emits `channel_left` or `group_left`, with `actor_id` and `event_ts`, for the affected app. Slack message text is limited to 40,000 Unicode characters across chat writes, incoming webhooks, and file upload initial comments. Longer text is truncated at a Unicode code point boundary before it is stored or dispatched. Successful Web API responses include `warning: "message_truncated"` and `response_metadata` with the matching warning and explanatory message. Rich fields such as `blocks` and `attachments` are preserved unchanged. diff --git a/packages/@emulators/slack/src/__tests__/app-mentions.test.ts b/packages/@emulators/slack/src/__tests__/app-mentions.test.ts index a41f23c4..a7aaa152 100644 --- a/packages/@emulators/slack/src/__tests__/app-mentions.test.ts +++ b/packages/@emulators/slack/src/__tests__/app-mentions.test.ts @@ -72,6 +72,7 @@ describe("Slack app mentions", () => { type: "event_callback", team_id: "T000000001", api_app_id: appId, + authorizations: [expect.objectContaining({ user_id: botUserId, is_bot: true })], event_id: expect.stringMatching(/^Ev[0-9a-f]{32}$/), event_time: expect.any(Number), event: expect.objectContaining({ diff --git a/packages/@emulators/slack/src/__tests__/event-payloads.test.ts b/packages/@emulators/slack/src/__tests__/event-payloads.test.ts new file mode 100644 index 00000000..0af573fe --- /dev/null +++ b/packages/@emulators/slack/src/__tests__/event-payloads.test.ts @@ -0,0 +1,309 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { getSlackStore, seedFromConfig } from "../index.js"; +import { + authHeaders, + captureFetchRequests, + createSlackTestApp, + registerSlackEventSubscription, + slackTestBaseUrl as base, + type SlackTestApp, +} from "./helpers.js"; + +const appId = "A_EVENT_APP"; +const botId = "B_EVENT_BOT"; +const botUserId = "U_EVENT_BOT"; +const botToken = "xoxb-event-bot"; + +function setupApp() { + const setup = createSlackTestApp(); + seedFromConfig(setup.store, base, { + verification_token: "event-verification-token", + users: [{ name: "alice" }], + channels: [{ name: "events" }, { name: "private-events", is_private: true }], + oauth_apps: [ + { + app_id: appId, + client_id: "event-client", + client_secret: "event-secret", + name: "Event Bot", + bot_id: botId, + bot_user_id: botUserId, + redirect_uris: ["https://example.com/callback"], + }, + ], + tokens: [{ token: botToken, type: "bot", user_id: botUserId, app_id: appId, bot_id: botId }], + }); + return setup; +} + +async function call(setup: SlackTestApp, method: string, body: Record, token?: string) { + const response = await setup.app.request(`${base}/api/${method}`, { + method: "POST", + headers: token ? { ...authHeaders(), Authorization: `Bearer ${token}` } : authHeaders(), + body: JSON.stringify(body), + }); + expect(response.status).toBe(200); + return (await response.json()) as Record; +} + +describe("Slack event payload fidelity", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it.each(["channel", "group", "im", "mpim"])("identifies %s message events across their lifecycle", async (kind) => { + const setup = setupApp(); + const ss = getSlackStore(setup.store); + const alice = ss.users.findOneBy("name", "alice")!; + const channel = + kind === "channel" || kind === "group" + ? ss.channels.findOneBy("name", kind === "channel" ? "events" : "private-events")!.channel_id + : ( + await call(setup, "conversations.open", { + users: kind === "im" ? botUserId : `${botUserId},${alice.user_id}`, + }) + ).channel.id; + const capture = captureFetchRequests(); + registerSlackEventSubscription(setup.webhooks, ["message"]); + + const posted = await call(setup, "chat.postMessage", { channel, text: "Hello" }); + await call(setup, "chat.update", { channel, ts: posted.ts, text: "Edited" }); + await call(setup, "chat.delete", { channel, ts: posted.ts }); + + const events = capture.jsonBodies() as Array>; + expect(events).toHaveLength(3); + for (const envelope of events) { + expect(envelope).toMatchObject({ + token: "event-verification-token", + api_app_id: appId, + team_id: "T000000001", + authorizations: [ + { + enterprise_id: null, + team_id: "T000000001", + user_id: botUserId, + is_bot: true, + is_enterprise_install: false, + }, + ], + event: { type: "message", channel, channel_type: kind, event_ts: expect.stringMatching(/^\d+\.\d+$/) }, + }); + } + expect(events[0].event.event_ts).toBe(posted.ts); + expect(events[1].event.message.ts).toBe(posted.ts); + expect(events[1].event.event_ts).not.toBe(posted.ts); + expect(events[2].event.deleted_ts).toBe(posted.ts); + expect(events[2].event.event_ts).not.toBe(posted.ts); + }); + + it("routes envelopes by recipient app, independently of the message author", async () => { + const setup = setupApp(); + seedFromConfig(setup.store, base, { + oauth_apps: [ + { + app_id: "A_SECOND", + client_id: "second", + client_secret: "second-secret", + name: "Second", + bot_user_id: "U_SECOND", + redirect_uris: [], + }, + ], + }); + const channel = getSlackStore(setup.store).channels.findOneBy("name", "events")!.channel_id; + const capture = captureFetchRequests(); + registerSlackEventSubscription(setup.webhooks, ["message"]); + setup.webhooks.register({ url: "https://hooks.example/other", events: ["message"], owner: "slack", active: true }); + + const posted = await call(setup, "chat.postMessage", { channel, text: "From the first app" }, botToken); + const envelopes = capture.jsonBodies() as Array>; + expect(envelopes).toHaveLength(4); + expect(envelopes[0]).toEqual(envelopes[1]); + expect(envelopes[2]).toEqual(envelopes[3]); + expect(envelopes[0].api_app_id).toBe(appId); + expect(envelopes[0].authorizations[0].user_id).toBe(botUserId); + expect(envelopes[2].api_app_id).toBe("A_SECOND"); + expect(envelopes[2].authorizations[0].user_id).toBe("U_SECOND"); + expect(envelopes[0].event_id).not.toBe(envelopes[2].event_id); + for (const envelope of envelopes) { + expect(envelope.event).toMatchObject({ user: botUserId, app_id: appId, bot_id: botId, ts: posted.ts }); + } + }); + + it("persists bot identity for self-message filters in responses, callbacks, history, and replies", async () => { + const setup = setupApp(); + const channel = getSlackStore(setup.store).channels.findOneBy("name", "events")!.channel_id; + const parent = await call(setup, "chat.postMessage", { channel, text: "Parent" }); + const root = await call(setup, "chat.postMessage", { channel, text: "Bot message" }, botToken); + const capture = captureFetchRequests(); + registerSlackEventSubscription(setup.webhooks, ["message"]); + const posted = await call( + setup, + "chat.postMessage", + { channel, text: "Bot reply", thread_ts: parent.ts, bot_id: "B_SPOOF", app_id: "A_SPOOF" }, + botToken, + ); + expect(posted.ok).toBe(true); + const expected = { + user: botUserId, + bot_id: botId, + app_id: appId, + bot_profile: { + id: botId, + app_id: appId, + name: "event-bot", + deleted: false, + updated: expect.any(Number), + team_id: "T000000001", + icons: expect.any(Object), + }, + }; + expect(posted.message).toMatchObject(expected); + expect((capture.jsonBodies()[0] as any).event).toMatchObject(expected); + const edited = await call( + setup, + "chat.update", + { channel, ts: posted.ts, text: "Updated bot reply", bot_id: "B_SPOOF", app_id: "A_SPOOF" }, + botToken, + ); + expect(edited.message).toMatchObject(expected); + expect(await call(setup, "chat.update", { channel, ts: posted.ts }, botToken)).toMatchObject({ error: "no_text" }); + const history = await call(setup, "conversations.history", { channel }); + const replies = await call(setup, "conversations.replies", { channel, ts: parent.ts }); + expect(history.messages.find((message: any) => message.ts === root.ts)).toMatchObject(expected); + expect(replies.messages.find((message: any) => message.ts === posted.ts)).toMatchObject(expected); + expect(history.messages.find((message: any) => message.ts === parent.ts)).not.toHaveProperty("bot_id"); + }); + + it("uses the bot identity of OAuth-issued tokens and keeps user tokens as human posts", async () => { + const setup = setupApp(); + const callback = await setup.app.request(`${base}/oauth/v2/authorize/callback`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + user_id: "U000000001", + redirect_uri: "https://example.com/callback", + client_id: "event-client", + scope: "chat:write", + user_scope: "chat:write", + }), + }); + expect(callback.status).toBe(302); + const code = new URL(callback.headers.get("Location")!).searchParams.get("code"); + const exchanged = await call(setup, "oauth.v2.access", { + code, + client_id: "event-client", + client_secret: "event-secret", + redirect_uri: "https://example.com/callback", + }); + expect(exchanged.ok).toBe(true); + const channel = getSlackStore(setup.store).channels.findOneBy("name", "events")!.channel_id; + const botPost = await call(setup, "chat.postMessage", { channel, text: "OAuth bot" }, exchanged.access_token); + expect(botPost.message).toMatchObject({ + user: botUserId, + bot_id: botId, + app_id: appId, + bot_profile: { id: botId }, + }); + const humanPost = await call( + setup, + "chat.postMessage", + { channel, text: "OAuth human" }, + exchanged.authed_user.access_token, + ); + expect(humanPost.message.user).toBe("U000000001"); + expect(humanPost.message).not.toHaveProperty("bot_id"); + expect(humanPost.message).not.toHaveProperty("bot_profile"); + }); + + it.each(["events", "private-events"])("notifies a removed app in %s for kick and leave", async (name) => { + const setup = setupApp(); + const channel = getSlackStore(setup.store).channels.findOneBy("name", name)!.channel_id; + await call(setup, "conversations.invite", { channel, users: botUserId }); + const capture = captureFetchRequests(); + registerSlackEventSubscription(setup.webhooks, ["member_left_channel", "channel_left", "group_left"]); + + for (const method of ["conversations.kick", "conversations.leave"]) { + if (method === "conversations.leave") await call(setup, "conversations.invite", { channel, users: botUserId }); + const response = await call( + setup, + method, + { channel, user: botUserId }, + method === "conversations.leave" ? botToken : undefined, + ); + expect(response.ok).toBe(true); + const events = capture.jsonBodies() as Array>; + const [memberLeft, appLeft] = events.slice(-2); + expect(memberLeft.event).toMatchObject({ + type: "member_left_channel", + user: botUserId, + channel, + event_ts: expect.any(String), + }); + expect(appLeft).toMatchObject({ + api_app_id: appId, + authorizations: [expect.objectContaining({ user_id: botUserId, is_bot: true })], + event: { + type: name === "events" ? "channel_left" : "group_left", + channel, + actor_id: method === "conversations.leave" ? botUserId : "U000000001", + event_ts: memberLeft.event.event_ts, + }, + }); + } + expect(capture.requests).toHaveLength(4); + const rejected = await call(setup, "conversations.kick", { channel, user: botUserId }); + expect(rejected.ok).toBe(false); + expect(capture.requests).toHaveLength(4); + }); + + it("does not emit bot departure callbacks for a human leaving", async () => { + const setup = setupApp(); + const ss = getSlackStore(setup.store); + const channel = ss.channels.findOneBy("name", "events")!.channel_id; + const alice = ss.users.findOneBy("name", "alice")!; + await call(setup, "conversations.invite", { channel, users: alice.user_id }); + const capture = captureFetchRequests(); + registerSlackEventSubscription(setup.webhooks, ["member_left_channel", "channel_left", "group_left"]); + await call(setup, "conversations.kick", { channel, user: alice.user_id }); + expect(capture.jsonBodies()).toEqual([ + expect.objectContaining({ event: expect.objectContaining({ type: "member_left_channel", user: alice.user_id }) }), + ]); + }); + + it("adds message event fields to incoming webhook posts", async () => { + const setup = setupApp(); + const capture = captureFetchRequests(); + registerSlackEventSubscription(setup.webhooks, ["message"]); + const response = await setup.app.request(`${base}/services/T000000001/${botId}/hook`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ channel: "events", text: "Webhook message" }), + }); + expect(await response.text()).toBe("ok"); + const event = (capture.jsonBodies()[0] as any).event; + expect(event).toMatchObject({ + type: "message", + channel_type: "channel", + event_ts: event.ts, + bot_id: botId, + app_id: appId, + bot_profile: { id: botId }, + }); + }); + + it("keeps a complete default envelope when no app is installed", async () => { + const setup = createSlackTestApp(); + const capture = captureFetchRequests(); + registerSlackEventSubscription(setup.webhooks, ["message"]); + await call(setup, "chat.postMessage", { channel: "general", text: "Development message" }); + expect(capture.jsonBodies()).toEqual([ + expect.objectContaining({ + token: "emulate-verification-token", + api_app_id: "A000000001", + authorizations: [expect.objectContaining({ user_id: "U000000001", is_bot: false })], + }), + ]); + }); +}); diff --git a/packages/@emulators/slack/src/__tests__/interactions.test.ts b/packages/@emulators/slack/src/__tests__/interactions.test.ts index 2d369376..d1472509 100644 --- a/packages/@emulators/slack/src/__tests__/interactions.test.ts +++ b/packages/@emulators/slack/src/__tests__/interactions.test.ts @@ -5,6 +5,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { getSlackStore, seedFromConfig } from "../index.js"; import { authHeaders, + captureFetchRequests, + registerSlackEventSubscription, createSlackTestApp, slackTestBaseUrl as base, startSlackTestEmulator, @@ -281,6 +283,8 @@ describe("Slack interactions", () => { receiver(); const clicked = await click(setup); const path = new URL(clicked.payload.response_url).pathname; + const capture = captureFetchRequests(); + registerSlackEventSubscription(setup.webhooks, ["message"]); const ss = getSlackStore(setup.store); const messages = ss.messages.all().length; expect(await post(setup, path, { text: "Only you" }, "")).toEqual({ ok: true }); @@ -295,13 +299,38 @@ describe("Slack interactions", () => { ), ).toEqual({ ok: true }); expect(ss.messages.findOneBy("ts", clicked.payload.message.ts)!.reply_count).toBe(1); - expect(await post(setup, path, { text: "Changed", replace_original: true }, "")).toEqual({ ok: true }); + const replies = await post(setup, "/api/conversations.replies", { + channel: ss.channels.findOneBy("name", "general")!.channel_id, + ts: clicked.payload.message.ts, + }); + expect(replies.messages.find((message: any) => message.text === "Public")).toMatchObject({ + bot_id: botId, + app_id: appId, + bot_profile: { id: botId, app_id: appId }, + }); + expect( + await post(setup, path, { text: "Changed", replace_original: true, bot_id: "B_SPOOF", app_id: "A_SPOOF" }, ""), + ).toEqual({ ok: true }); expect(ss.messages.findOneBy("ts", clicked.payload.message.ts)).toMatchObject({ text: "Changed", blocks: undefined, + bot_id: botId, + app_id: appId, + bot_profile: { id: botId, app_id: appId }, }); expect(await post(setup, path, { delete_original: true }, "")).toEqual({ ok: true }); expect(ss.messages.findOneBy("ts", clicked.payload.message.ts)).toBeUndefined(); + const envelopes = capture.jsonBodies() as Array>; + expect(envelopes).toHaveLength(3); + for (const envelope of envelopes) { + expect(envelope).toMatchObject({ + api_app_id: appId, + authorizations: [expect.objectContaining({ user_id: botUserId, is_bot: true })], + event: { type: "message", channel_type: "channel", event_ts: expect.any(String) }, + }); + const message = envelope.event.message ?? envelope.event.previous_message ?? envelope.event; + expect(message).toMatchObject({ bot_id: botId, app_id: appId, bot_profile: { id: botId, app_id: appId } }); + } }); it("keeps ephemeral interactions and replacements private to the acting user", async () => { diff --git a/packages/@emulators/slack/src/__tests__/slack-connect.test.ts b/packages/@emulators/slack/src/__tests__/slack-connect.test.ts index 79a5e936..08fa84bc 100644 --- a/packages/@emulators/slack/src/__tests__/slack-connect.test.ts +++ b/packages/@emulators/slack/src/__tests__/slack-connect.test.ts @@ -173,6 +173,7 @@ describe("Slack Connect fixtures", () => { team_id: localTeam, is_ext_shared_channel: true, api_app_id: "A_CONNECT", + authorizations: [expect.objectContaining({ team_id: localTeam, user_id: botUser, is_bot: true })], event: expect.objectContaining({ type: "app_mention", user, diff --git a/packages/@emulators/slack/src/__tests__/slack-coverage.ts b/packages/@emulators/slack/src/__tests__/slack-coverage.ts index 0ffc8533..ed55ed4a 100644 --- a/packages/@emulators/slack/src/__tests__/slack-coverage.ts +++ b/packages/@emulators/slack/src/__tests__/slack-coverage.ts @@ -50,16 +50,22 @@ export const slackCoverageMatrix: SlackCoverageEntry[] = [ method: "chat.postMessage", route: "POST /api/chat.postMessage", status: "partial", - testedBy: ["slack-connect.test.ts", "slack.test.ts", "slack-sdk.test.ts", "slack-events.test.ts"], + testedBy: [ + "event-payloads.test.ts", + "slack-connect.test.ts", + "slack.test.ts", + "slack-sdk.test.ts", + "slack-events.test.ts", + ], notes: - "Text is limited to 40,000 Unicode code points with Slack message_truncated warning metadata. Thread replies, blocks, attachments, metadata, formatting flags, unfurl flags, client message ids, and DM user id posting round trip. Full membership checks are future work.", + "Text is limited to 40,000 Unicode code points with Slack message_truncated warning metadata. Thread replies, blocks, attachments, metadata, formatting flags, unfurl flags, client message ids, and DM user id posting round trip. Bot messages persist bot_id, app_id, and bot_profile; callbacks include recipient app authorization, channel_type, and event_ts. Full membership checks are future work.", }, { family: "chat", method: "chat.update", route: "POST /api/chat.update", status: "partial", - testedBy: ["slack.test.ts", "slack-sdk.test.ts", "slack-events.test.ts"], + testedBy: ["event-payloads.test.ts", "slack.test.ts", "slack-sdk.test.ts", "slack-events.test.ts"], notes: "Stored text is limited to 40,000 Unicode code points with Slack message_truncated warning metadata. Rich message fields are updated and message_changed events are dispatched.", }, @@ -68,7 +74,7 @@ export const slackCoverageMatrix: SlackCoverageEntry[] = [ method: "chat.delete", route: "POST /api/chat.delete", status: "partial", - testedBy: ["slack.test.ts", "slack-sdk.test.ts", "slack-events.test.ts"], + testedBy: ["event-payloads.test.ts", "slack.test.ts", "slack-sdk.test.ts", "slack-events.test.ts"], notes: "Stored messages are deleted and message_deleted events are dispatched.", }, { @@ -196,7 +202,7 @@ export const slackCoverageMatrix: SlackCoverageEntry[] = [ method: "conversations.history", route: "POST /api/conversations.history", status: "partial", - testedBy: ["slack.test.ts", "slack-sdk.test.ts"], + testedBy: ["event-payloads.test.ts", "slack.test.ts", "slack-sdk.test.ts"], notes: "Returns top level messages for a channel, including rich message fields. Time filtering is future work.", }, { @@ -204,7 +210,7 @@ export const slackCoverageMatrix: SlackCoverageEntry[] = [ method: "conversations.replies", route: "POST /api/conversations.replies", status: "partial", - testedBy: ["slack.test.ts", "slack-sdk.test.ts"], + testedBy: ["event-payloads.test.ts", "slack.test.ts", "slack-sdk.test.ts"], notes: "Returns parent and replies for a thread, including rich message fields.", }, { @@ -220,8 +226,8 @@ export const slackCoverageMatrix: SlackCoverageEntry[] = [ method: "conversations.leave", route: "POST /api/conversations.leave", status: "partial", - testedBy: ["slack-connect.test.ts", "slack.test.ts", "slack-sdk.test.ts"], - notes: "Removes the auth user from channel membership.", + testedBy: ["event-payloads.test.ts", "slack-connect.test.ts", "slack.test.ts", "slack-sdk.test.ts"], + notes: "Removes the auth user from channel membership and emits channel_left or group_left for installed bots.", }, { family: "conversations", @@ -245,8 +251,15 @@ export const slackCoverageMatrix: SlackCoverageEntry[] = [ method: "conversations.kick", route: "POST /api/conversations.kick", status: "partial", - testedBy: ["slack-connect.test.ts", "slack.test.ts", "slack-sdk.test.ts", "slack-events.test.ts"], - notes: "Removes users from public/private channels and MPIMs, with basic errors and member_left_channel events.", + testedBy: [ + "event-payloads.test.ts", + "slack-connect.test.ts", + "slack.test.ts", + "slack-sdk.test.ts", + "slack-events.test.ts", + ], + notes: + "Removes users from public/private channels and MPIMs, with basic errors and member_left_channel events, plus channel_left or group_left for removed installed bots.", }, { family: "conversations", diff --git a/packages/@emulators/slack/src/__tests__/slack-events.test.ts b/packages/@emulators/slack/src/__tests__/slack-events.test.ts index f26c7c3f..d9f4ca0b 100644 --- a/packages/@emulators/slack/src/__tests__/slack-events.test.ts +++ b/packages/@emulators/slack/src/__tests__/slack-events.test.ts @@ -17,7 +17,16 @@ function captureFetchRequests(teamId = "T000000001") { jsonBodies: () => { const bodies = capture.jsonBodies(); for (const body of bodies) { - expect(body).toMatchObject({ type: "event_callback", team_id: teamId, event: expect.any(Object) }); + expect(body).toMatchObject({ + type: "event_callback", + team_id: teamId, + token: expect.any(String), + api_app_id: expect.any(String), + authorizations: [ + expect.objectContaining({ team_id: teamId, user_id: expect.any(String), is_bot: expect.any(Boolean) }), + ], + event: expect.objectContaining({ event_ts: expect.stringMatching(/^\d+\.\d+$/) }), + }); const envelope = body as { event_id: string; event_time: number }; expect(envelope.event_id).toMatch(/^Ev[0-9a-f]{32}$/); expect(Number.isInteger(envelope.event_time)).toBe(true); diff --git a/packages/@emulators/slack/src/entities.ts b/packages/@emulators/slack/src/entities.ts index 41b89377..727bf4f4 100644 --- a/packages/@emulators/slack/src/entities.ts +++ b/packages/@emulators/slack/src/entities.ts @@ -101,6 +101,15 @@ export interface SlackMessage extends Entity { icon_emoji?: string; bot_id?: string; app_id?: string; + bot_profile?: { + id: string; + app_id?: string; + name: string; + deleted: boolean; + updated: number; + team_id: string; + icons: { image_36: string; image_48: string; image_72: string }; + }; client_msg_id?: string; reply_broadcast?: boolean; topic?: string; diff --git a/packages/@emulators/slack/src/events.ts b/packages/@emulators/slack/src/events.ts index 0f15e685..6469ded4 100644 --- a/packages/@emulators/slack/src/events.ts +++ b/packages/@emulators/slack/src/events.ts @@ -1,10 +1,41 @@ import { randomUUID } from "node:crypto"; -import type { Context, Store } from "@emulators/core"; +import type { Context, Store, WebhookDispatcher } from "@emulators/core"; import { isSlackConnectChannel } from "./connect.js"; +import type { SlackInstallation } from "./entities.js"; +import { generateTs } from "./helpers.js"; import { getSlackStore } from "./store.js"; -export function buildSlackEventEnvelope(store: Store, teamId: string, event: Record) { +export async function dispatchSlackEvent( + store: Store, + webhooks: WebhookDispatcher, + teamId: string, + event: Record, + recipient?: SlackInstallation, +) { const ss = getSlackStore(store); + const installations = recipient ? [recipient] : ss.installations.findBy("team_id", teamId); + const normalizedEvent = { + event_ts: event.type === "message" && typeof event.ts === "string" ? event.ts : generateTs(), + ...event, + }; + for (const installation of installations.length > 0 ? installations : [undefined]) { + await webhooks.dispatch( + String(event.type), + undefined, + buildSlackEventEnvelope(store, teamId, normalizedEvent, installation), + "slack", + ); + } +} + +function buildSlackEventEnvelope( + store: Store, + teamId: string, + event: Record, + installation?: SlackInstallation, +) { + const ss = getSlackStore(store); + const user = ss.users.all().find((user) => user.team_id === teamId && !user.deleted); const channelId = typeof event.channel === "string" ? event.channel @@ -16,14 +47,30 @@ export function buildSlackEventEnvelope(store: Store, teamId: string, event: Rec const author = typeof event.user === "string" ? ss.users.findOneBy("user_id", event.user) : undefined; return { type: "event_callback" as const, + token: store.getData("slack.verification_token") ?? "emulate-verification-token", team_id: teamId, + api_app_id: installation?.app_id ?? "A000000001", + authorizations: [ + { + enterprise_id: null, + team_id: teamId, + user_id: installation?.bot_user_id ?? user?.user_id ?? "U000000001", + is_bot: installation ? true : (user?.is_bot ?? false), + is_enterprise_install: false, + }, + ], event_id: `Ev${randomUUID().replaceAll("-", "")}`, event_time: Math.floor(Date.now() / 1000), ...(channel ? { is_ext_shared_channel: shared } : {}), - event: - shared && event.type === "app_mention" && author && author.team_id !== teamId - ? { ...event, user_team: author.team_id, source_team: author.team_id } - : event, + event: { + ...(event.type === "message" && channel + ? { channel_type: channel.is_im ? "im" : channel.is_mpim ? "mpim" : channel.is_private ? "group" : "channel" } + : {}), + ...event, + ...(shared && event.type === "app_mention" && author && author.team_id !== teamId + ? { user_team: author.team_id, source_team: author.team_id } + : {}), + }, }; } diff --git a/packages/@emulators/slack/src/helpers.ts b/packages/@emulators/slack/src/helpers.ts index 97853d20..a8781101 100644 --- a/packages/@emulators/slack/src/helpers.ts +++ b/packages/@emulators/slack/src/helpers.ts @@ -2,6 +2,7 @@ import { randomBytes } from "crypto"; import type { Context } from "@emulators/core"; import type { ContentfulStatusCode } from "@emulators/core"; import type { Store } from "@emulators/core"; +import { getSlackStore } from "./store.js"; import type { SlackChannel, SlackFile, @@ -186,6 +187,37 @@ export async function parseSlackBody(c: Context): Promise { + const bot = getSlackStore(store).bots.findOneBy("bot_id", botId); + const resolvedAppId = appId ?? bot?.app_id; + return { + bot_id: botId, + ...(resolvedAppId ? { app_id: resolvedAppId } : {}), + ...(bot + ? { + bot_profile: { + id: botId, + ...(resolvedAppId ? { app_id: resolvedAppId } : {}), + name: bot.name, + deleted: bot.deleted, + updated: Math.floor(Date.parse(bot.updated_at) / 1000), + team_id: teamId, + icons: { + image_36: bot.icons.image_48, + image_48: bot.icons.image_48, + image_72: bot.icons.image_48, + }, + }, + } + : {}), + }; +} + export function formatSlackMessage(msg: SlackMessage) { return { type: msg.type, @@ -195,6 +227,7 @@ export function formatSlackMessage(msg: SlackMessage) { ...(msg.subtype ? { subtype: msg.subtype } : {}), ...(msg.bot_id ? { bot_id: msg.bot_id } : {}), ...(msg.app_id ? { app_id: msg.app_id } : {}), + ...(msg.bot_profile ? { bot_profile: msg.bot_profile } : {}), ...(msg.username ? { username: msg.username } : {}), ...(msg.icon_url ? { icon_url: msg.icon_url } : {}), ...(msg.icon_emoji ? { icon_emoji: msg.icon_emoji } : {}), diff --git a/packages/@emulators/slack/src/index.ts b/packages/@emulators/slack/src/index.ts index 33f744cd..fe79e1cb 100644 --- a/packages/@emulators/slack/src/index.ts +++ b/packages/@emulators/slack/src/index.ts @@ -83,6 +83,7 @@ export interface SlackSeedConfig { }>; strict_scopes?: boolean; signing_secret?: string; + verification_token?: string; view_trigger_ttl_seconds?: number; } @@ -376,6 +377,10 @@ export function seedFromConfig(store: Store, _baseUrl: string, config: SlackSeed store.setData("slack.signing_secret", config.signing_secret); } + if (config.verification_token !== undefined) { + store.setData("slack.verification_token", config.verification_token); + } + if (config.strict_scopes !== undefined) { store.setData("slack.strict_scopes", config.strict_scopes); } diff --git a/packages/@emulators/slack/src/routes/apps.ts b/packages/@emulators/slack/src/routes/apps.ts index c8a65120..f954b1ed 100644 --- a/packages/@emulators/slack/src/routes/apps.ts +++ b/packages/@emulators/slack/src/routes/apps.ts @@ -1,6 +1,6 @@ import { constantTimeSecretEqual, type RouteContext } from "@emulators/core"; -import { buildSlackEventEnvelope } from "../events.js"; -import { generateTs, parseSlackBody, slackError, slackOk } from "../helpers.js"; +import { dispatchSlackEvent } from "../events.js"; +import { parseSlackBody, slackError, slackOk } from "../helpers.js"; import { getSlackStore } from "../store.js"; import { isSlackTokenRevoked, revokeSlackTokens, slackRequestToken } from "../tokens.js"; @@ -67,25 +67,7 @@ export function appsRoutes({ app, store, webhooks, tokenMap }: RouteContext): vo // Capture the recipient before removing its installation. Credentials are // already invalid when either callback reaches the application. const dispatch = async (event: Record) => { - await webhooks.dispatch( - String(event.type), - undefined, - { - ...buildSlackEventEnvelope(store, installation.team_id, { event_ts: generateTs(), ...event }), - api_app_id: installation.app_id, - token: store.getData("slack.verification_token") ?? "emulate-verification-token", - authorizations: [ - { - enterprise_id: null, - team_id: installation.team_id, - user_id: installation.bot_user_id, - is_bot: true, - is_enterprise_install: false, - }, - ], - }, - "slack", - ); + await dispatchSlackEvent(store, webhooks, installation.team_id, event, installation); }; await dispatch({ type: "app_uninstalled" }); await dispatch({ type: "tokens_revoked", tokens: revokedUsers }); diff --git a/packages/@emulators/slack/src/routes/chat.ts b/packages/@emulators/slack/src/routes/chat.ts index 187e1f0d..e8039904 100644 --- a/packages/@emulators/slack/src/routes/chat.ts +++ b/packages/@emulators/slack/src/routes/chat.ts @@ -1,6 +1,6 @@ import type { Context, RouteContext } from "@emulators/core"; import type { SlackChannel, SlackMessage, SlackUser } from "../entities.js"; -import { buildSlackEventEnvelope, resolveSlackEventTeamId } from "../events.js"; +import { dispatchSlackEvent, resolveSlackEventTeamId } from "../events.js"; import { getSlackStore } from "../store.js"; import { formatSlackMessage, @@ -17,6 +17,7 @@ import { requireSlackScopes, setSlackConversationOpenState, slackError, + slackBotMessageFields, slackMessageTextResponseMetadata, slackOk, } from "../helpers.js"; @@ -67,12 +68,7 @@ export function chatRoutes(ctx: RouteContext): void { event: Record, teamId?: string, ) => { - await webhooks.dispatch( - type, - undefined, - buildSlackEventEnvelope(store, resolveSlackEventTeamId(c, store, teamId), { type, ...event }), - "slack", - ); + await dispatchSlackEvent(store, webhooks, resolveSlackEventTeamId(c, store, teamId), { type, ...event }); }; const dispatchAppMentions = async (c: Context, channel: SlackChannel, message: SlackMessage) => { if (channel.is_im || channel.is_mpim) return; @@ -89,19 +85,17 @@ export function chatRoutes(ctx: RouteContext): void { continue; } - await webhooks.dispatch( - "app_mention", - undefined, + await dispatchSlackEvent( + store, + webhooks, + teamId, { - ...buildSlackEventEnvelope(store, teamId, { - ...formatSlackMessage(message), - type: "app_mention", - channel: channel.channel_id, - event_ts: message.ts, - }), - api_app_id: installation.app_id, + ...formatSlackMessage(message), + type: "app_mention", + channel: channel.channel_id, + event_ts: message.ts, }, - "slack", + installation, ); } }; @@ -185,6 +179,15 @@ export function chatRoutes(ctx: RouteContext): void { const authUserId = getAuthUserId(authUser); const ts = generateTs(); + const token = c.get("authToken"); + const authToken = token ? ss().tokens.findOneBy("token", token) : undefined; + const bot = + authToken?.token_type === "bot" + ? ss() + .bots.all() + .find((bot) => bot.bot_id === authToken.bot_id || bot.user_id === authUserId) + : undefined; + const botId = authToken?.token_type === "bot" ? (authToken.bot_id ?? bot?.bot_id) : undefined; const msg = ss().messages.insert({ ts, interaction_app_id: messageAppId(c), @@ -194,6 +197,7 @@ export function chatRoutes(ctx: RouteContext): void { type: "message" as const, thread_ts, ...richMessage.fields, + ...(botId ? slackBotMessageFields(store, botId, ch.team_id, authToken?.app_id) : {}), reply_count: 0, reply_users: [], reactions: [], @@ -215,16 +219,11 @@ export function chatRoutes(ctx: RouteContext): void { } } - await webhooks.dispatch( - "message", - undefined, - buildSlackEventEnvelope(store, resolveSlackEventTeamId(c, store, ch.team_id), { - ...formatSlackMessage(msg), - type: "message", - channel: ch.channel_id, - }), - "slack", - ); + await dispatchSlackEvent(store, webhooks, resolveSlackEventTeamId(c, store, ch.team_id), { + ...formatSlackMessage(msg), + type: "message", + channel: ch.channel_id, + }); await dispatchAppMentions(c, ch, msg); return slackOk(c, { @@ -321,6 +320,10 @@ export function chatRoutes(ctx: RouteContext): void { if (!hasText && Object.keys(updates).length === 0) { return slackError(c, "no_text"); } + if (msg.bot_id) { + updates.bot_id = msg.bot_id; + updates.app_id = msg.app_id; + } const authUserId = getAuthUserId(authUser); const eventTs = generateTs(); @@ -329,21 +332,16 @@ export function chatRoutes(ctx: RouteContext): void { edited: { user: authUserId, ts: eventTs }, })!; - await webhooks.dispatch( - "message", - undefined, - buildSlackEventEnvelope(store, resolveSlackEventTeamId(c, store, ch?.team_id), { - type: "message", - subtype: "message_changed", - hidden: true, - channel, - ts: eventTs, - event_ts: eventTs, - message: formatSlackMessage(updated), - previous_message: formatSlackMessage(msg), - }), - "slack", - ); + await dispatchSlackEvent(store, webhooks, resolveSlackEventTeamId(c, store, ch?.team_id), { + type: "message", + subtype: "message_changed", + hidden: true, + channel, + ts: eventTs, + event_ts: eventTs, + message: formatSlackMessage(updated), + previous_message: formatSlackMessage(msg), + }); return slackOk(c, { channel, @@ -380,21 +378,16 @@ export function chatRoutes(ctx: RouteContext): void { deletePinsForMessage(channel, ts); const eventTs = generateTs(); - await webhooks.dispatch( - "message", - undefined, - buildSlackEventEnvelope(store, resolveSlackEventTeamId(c, store, ch?.team_id), { - type: "message", - subtype: "message_deleted", - hidden: true, - channel, - ts: eventTs, - event_ts: eventTs, - deleted_ts: ts, - previous_message: formatSlackMessage(msg), - }), - "slack", - ); + await dispatchSlackEvent(store, webhooks, resolveSlackEventTeamId(c, store, ch?.team_id), { + type: "message", + subtype: "message_deleted", + hidden: true, + channel, + ts: eventTs, + event_ts: eventTs, + deleted_ts: ts, + previous_message: formatSlackMessage(msg), + }); return slackOk(c, { channel, ts }); }); diff --git a/packages/@emulators/slack/src/routes/conversations.ts b/packages/@emulators/slack/src/routes/conversations.ts index 9c8e54d6..f704b3c3 100644 --- a/packages/@emulators/slack/src/routes/conversations.ts +++ b/packages/@emulators/slack/src/routes/conversations.ts @@ -1,6 +1,6 @@ import type { Context, RouteContext } from "@emulators/core"; import type { SlackChannel, SlackFile, SlackFileShare, SlackMessage, SlackUser } from "../entities.js"; -import { buildSlackEventEnvelope, resolveSlackEventTeamId } from "../events.js"; +import { dispatchSlackEvent, resolveSlackEventTeamId } from "../events.js"; import { isSlackConnectChannel } from "../connect.js"; import { getSlackStore } from "../store.js"; import { slackRequestToken } from "../tokens.js"; @@ -82,12 +82,7 @@ export function conversationsRoutes(ctx: RouteContext): void { type: string, event: Record, ) => { - await webhooks.dispatch( - type, - undefined, - buildSlackEventEnvelope(store, resolveSlackEventTeamId(c, store, channel.team_id), { type, ...event }), - "slack", - ); + await dispatchSlackEvent(store, webhooks, resolveSlackEventTeamId(c, store, channel.team_id), { type, ...event }); }; const insertAndDispatchMessageEvent = async ( c: Context, @@ -107,17 +102,11 @@ export function conversationsRoutes(ctx: RouteContext): void { reactions: [], }); - await webhooks.dispatch( - "message", - undefined, - buildSlackEventEnvelope(store, resolveSlackEventTeamId(c, store, channel.team_id), { - ...formatSlackMessage(msg), - channel: channel.channel_id, - channel_type: channel.is_im ? "im" : channel.is_mpim ? "mpim" : conversationEventPrefix(channel), - event_ts: msg.ts, - }), - "slack", - ); + await dispatchSlackEvent(store, webhooks, resolveSlackEventTeamId(c, store, channel.team_id), { + ...formatSlackMessage(msg), + channel: channel.channel_id, + event_ts: msg.ts, + }); return msg; }; const dispatchMemberJoined = async (c: Context, channel: SlackChannel, user: string, inviter?: string) => { @@ -130,12 +119,29 @@ export function conversationsRoutes(ctx: RouteContext): void { }); }; const dispatchMemberLeft = async (c: Context, channel: SlackChannel, user: string) => { + const eventTs = generateTs(); await dispatchConversationEvent(c, channel, "member_left_channel", { user, channel: channel.channel_id, channel_type: channelTypeLetter(channel), team: channel.team_id, + event_ts: eventTs, }); + for (const installation of ss().installations.findBy("team_id", channel.team_id)) { + if (installation.bot_user_id !== user) continue; + await dispatchSlackEvent( + store, + webhooks, + channel.team_id, + { + type: channel.is_private ? "group_left" : "channel_left", + channel: channel.channel_id, + actor_id: getAuthUserId(c.get("authUser")!), + event_ts: eventTs, + }, + installation, + ); + } }; for (const [method, isPrivate] of [ diff --git a/packages/@emulators/slack/src/routes/files.ts b/packages/@emulators/slack/src/routes/files.ts index 73c84100..b6bafc0e 100644 --- a/packages/@emulators/slack/src/routes/files.ts +++ b/packages/@emulators/slack/src/routes/files.ts @@ -1,5 +1,5 @@ import type { Context, RouteContext } from "@emulators/core"; -import { buildSlackEventEnvelope, resolveSlackEventTeamId } from "../events.js"; +import { dispatchSlackEvent, resolveSlackEventTeamId } from "../events.js"; import type { SlackChannel, SlackFile, @@ -303,17 +303,12 @@ export function filesRoutes(ctx: RouteContext): void { } const updatedMessage = ss().messages.update(msg.id, { files: messageFiles })!; - await webhooks.dispatch( - "message", - undefined, - buildSlackEventEnvelope(store, resolveSlackEventTeamId(c, store, channel.team_id), { - ...formatSlackMessage(updatedMessage), - type: "message", - subtype: "file_share", - channel: channel.channel_id, - }), - "slack", - ); + await dispatchSlackEvent(store, webhooks, resolveSlackEventTeamId(c, store, channel.team_id), { + ...formatSlackMessage(updatedMessage), + type: "message", + subtype: "file_share", + channel: channel.channel_id, + }); for (const shared of messageFiles) { const index = updatedFiles.findIndex((file) => file.file_id === shared.file_id); @@ -720,15 +715,10 @@ async function dispatchFileEvent( teamId: string, extra: Record = {}, ) { - await webhooks.dispatch( + await dispatchSlackEvent(store, webhooks, teamId, { type, - undefined, - buildSlackEventEnvelope(store, teamId, { - type, - file_id: file.file_id, - file: formatSlackFile(file), - ...extra, - }), - "slack", - ); + file_id: file.file_id, + file: formatSlackFile(file), + ...extra, + }); } diff --git a/packages/@emulators/slack/src/routes/interactions.ts b/packages/@emulators/slack/src/routes/interactions.ts index eea228b3..d4d92af0 100644 --- a/packages/@emulators/slack/src/routes/interactions.ts +++ b/packages/@emulators/slack/src/routes/interactions.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; import type { RouteContext } from "@emulators/core"; import type { SlackJsonObject, SlackMessage, SlackView } from "../entities.js"; -import { buildSlackEventEnvelope } from "../events.js"; +import { dispatchSlackEvent } from "../events.js"; import { formatSlackMessage, formatSlackView, @@ -10,6 +10,7 @@ import { normalizeSlackMessageText, parseSlackBody, parseSlackRichMessageFields, + slackBotMessageFields, slackError, slackOk, } from "../helpers.js"; @@ -329,6 +330,7 @@ export function interactionRoutes({ app, store, baseUrl, webhooks }: RouteContex if (replace && source) { const updated = sourceCollection.update(source.id, { ...rich.fields, + ...(source.bot_id ? { bot_id: source.bot_id, app_id: source.app_id } : {}), ...(typeof body.text === "string" ? { text: normalized.text, blocks: rich.fields.blocks, attachments: rich.fields.attachments } : {}), @@ -349,8 +351,7 @@ export function interactionRoutes({ app, store, baseUrl, webhooks }: RouteContex ts: generateTs(), channel_id: response.channel_id, user: response.bot_user_id, - bot_id: response.bot_id, - app_id: response.app_id, + ...slackBotMessageFields(store, response.bot_id, channel.team_id, response.app_id), interaction_app_id: response.app_id, text: normalized.text, type: "message", @@ -383,12 +384,7 @@ export function interactionRoutes({ app, store, baseUrl, webhooks }: RouteContex }); async function messageEvent(teamId: string, event: SlackJsonObject) { - await webhooks.dispatch( - "message", - undefined, - buildSlackEventEnvelope(store, teamId, { event_ts: generateTs(), ...event }), - "slack", - ); + await dispatchSlackEvent(store, webhooks, teamId, event); } } diff --git a/packages/@emulators/slack/src/routes/pins.ts b/packages/@emulators/slack/src/routes/pins.ts index 4d121eed..b231a627 100644 --- a/packages/@emulators/slack/src/routes/pins.ts +++ b/packages/@emulators/slack/src/routes/pins.ts @@ -1,6 +1,6 @@ import type { Context, RouteContext } from "@emulators/core"; import type { SlackChannel, SlackMessage, SlackPin, SlackUser } from "../entities.js"; -import { buildSlackEventEnvelope, resolveSlackEventTeamId } from "../events.js"; +import { dispatchSlackEvent, resolveSlackEventTeamId } from "../events.js"; import { getSlackStore } from "../store.js"; import { formatSlackMessage, @@ -167,12 +167,7 @@ export function pinsRoutes(ctx: RouteContext): void { type: "pin_added" | "pin_removed", event: Record, ) { - await webhooks.dispatch( - type, - undefined, - buildSlackEventEnvelope(store, resolveSlackEventTeamId(c, store, channel.team_id), { type, ...event }), - "slack", - ); + await dispatchSlackEvent(store, webhooks, resolveSlackEventTeamId(c, store, channel.team_id), { type, ...event }); } } diff --git a/packages/@emulators/slack/src/routes/reactions.ts b/packages/@emulators/slack/src/routes/reactions.ts index c0d79cb7..53e97868 100644 --- a/packages/@emulators/slack/src/routes/reactions.ts +++ b/packages/@emulators/slack/src/routes/reactions.ts @@ -1,6 +1,6 @@ import type { RouteContext } from "@emulators/core"; import type { SlackChannel } from "../entities.js"; -import { buildSlackEventEnvelope, resolveSlackEventTeamId } from "../events.js"; +import { dispatchSlackEvent, resolveSlackEventTeamId } from "../events.js"; import { getSlackStore } from "../store.js"; import { formatSlackMessage, slackOk, slackError, parseSlackBody, requireSlackScopes } from "../helpers.js"; @@ -60,17 +60,12 @@ export function reactionsRoutes(ctx: RouteContext): void { ss().messages.update(msg.id, { reactions }); - await webhooks.dispatch( - "reaction_added", - undefined, - buildSlackEventEnvelope(store, resolveSlackEventTeamId(c, store, ch?.team_id), { - type: "reaction_added", - user: authUserId, - reaction: name, - item: { type: "message", channel, ts: timestamp }, - }), - "slack", - ); + await dispatchSlackEvent(store, webhooks, resolveSlackEventTeamId(c, store, ch?.team_id), { + type: "reaction_added", + user: authUserId, + reaction: name, + item: { type: "message", channel, ts: timestamp }, + }); return slackOk(c, {}); }); @@ -111,17 +106,12 @@ export function reactionsRoutes(ctx: RouteContext): void { const filtered = reactions.filter((r) => r.count > 0); ss().messages.update(msg.id, { reactions: filtered }); - await webhooks.dispatch( - "reaction_removed", - undefined, - buildSlackEventEnvelope(store, resolveSlackEventTeamId(c, store, ch?.team_id), { - type: "reaction_removed", - user: authUserId, - reaction: name, - item: { type: "message", channel, ts: timestamp }, - }), - "slack", - ); + await dispatchSlackEvent(store, webhooks, resolveSlackEventTeamId(c, store, ch?.team_id), { + type: "reaction_removed", + user: authUserId, + reaction: name, + item: { type: "message", channel, ts: timestamp }, + }); return slackOk(c, {}); }); diff --git a/packages/@emulators/slack/src/routes/users.ts b/packages/@emulators/slack/src/routes/users.ts index 8b156cad..036c182c 100644 --- a/packages/@emulators/slack/src/routes/users.ts +++ b/packages/@emulators/slack/src/routes/users.ts @@ -1,5 +1,5 @@ import type { Context, RouteContext } from "@emulators/core"; -import { buildSlackEventEnvelope, resolveSlackEventTeamId } from "../events.js"; +import { dispatchSlackEvent, resolveSlackEventTeamId } from "../events.js"; import { getSlackStore } from "../store.js"; import { generateTs, @@ -117,16 +117,11 @@ export function usersRoutes(ctx: RouteContext): void { if (updates.email !== undefined) userUpdates.email = nextProfile.email; const updated = ss().users.update(user.id, userUpdates)!; - await webhooks.dispatch( - "user_change", - undefined, - buildSlackEventEnvelope(store, resolveSlackEventTeamId(c, store, updated.team_id), { - type: "user_change", - user: formatUser(updated), - cache_ts: Number(generateTs().replace(".", "")), - }), - "slack", - ); + await dispatchSlackEvent(store, webhooks, resolveSlackEventTeamId(c, store, updated.team_id), { + type: "user_change", + user: formatUser(updated), + cache_ts: Number(generateTs().replace(".", "")), + }); return slackOk(c, { profile: formatProfile(updated.profile, canExposeEmail(c)) }); } @@ -183,16 +178,11 @@ export function usersRoutes(ctx: RouteContext): void { last_activity: nextPresence === "active" ? now : user.last_activity, })!; - await webhooks.dispatch( - "presence_change", - undefined, - buildSlackEventEnvelope(store, resolveSlackEventTeamId(c, store, updated.team_id), { - type: "presence_change", - user: updated.user_id, - presence: nextPresence, - }), - "slack", - ); + await dispatchSlackEvent(store, webhooks, resolveSlackEventTeamId(c, store, updated.team_id), { + type: "presence_change", + user: updated.user_id, + presence: nextPresence, + }); return slackOk(c, {}); } diff --git a/packages/@emulators/slack/src/routes/webhooks.ts b/packages/@emulators/slack/src/routes/webhooks.ts index e5c81e01..6ca821b8 100644 --- a/packages/@emulators/slack/src/routes/webhooks.ts +++ b/packages/@emulators/slack/src/routes/webhooks.ts @@ -1,12 +1,13 @@ import type { RouteContext } from "@emulators/core"; import { getSlackStore } from "../store.js"; -import { buildSlackEventEnvelope } from "../events.js"; +import { dispatchSlackEvent } from "../events.js"; import { formatSlackMessage, generateTs, hasSlackMessageContent, normalizeSlackMessageText, parseSlackRichMessageFields, + slackBotMessageFields, } from "../helpers.js"; export function webhookRoutes(ctx: RouteContext): void { @@ -90,7 +91,7 @@ export function webhookRoutes(ctx: RouteContext): void { subtype: "bot_message", thread_ts: threadTs, ...richMessage.fields, - bot_id: botId, + ...slackBotMessageFields(store, botId, targetChannel.team_id), reply_count: 0, reply_users: [], reactions: [], @@ -98,18 +99,13 @@ export function webhookRoutes(ctx: RouteContext): void { const { user: _user, ...eventMessage } = formatSlackMessage(msg); - await webhooks.dispatch( - "message", - undefined, - buildSlackEventEnvelope(store, webhook?.team_id ?? targetChannel.team_id, { - ...eventMessage, - type: "message", - subtype: "bot_message", - channel: targetChannel.channel_id, - bot_id: botId, - }), - "slack", - ); + await dispatchSlackEvent(store, webhooks, webhook?.team_id ?? targetChannel.team_id, { + ...eventMessage, + type: "message", + subtype: "bot_message", + channel: targetChannel.channel_id, + bot_id: botId, + }); return c.text("ok"); }); diff --git a/packages/emulate/src/index.ts b/packages/emulate/src/index.ts index 909a3860..7a4db9b1 100644 --- a/packages/emulate/src/index.ts +++ b/packages/emulate/src/index.ts @@ -146,13 +146,18 @@ Slack event callbacks: POST /_slack/simulate/event-retry accepts event_id and optional reason (default http_timeout). It resends the latest recorded delivery with increasing retry headers and a fresh Slack signature when configured. Retries are explicit, target one original subscription, and require retained in-memory delivery history. - Emitted event_callback payloads include team_id, event_id, and Unix-seconds event_time. + Event callbacks include token, api_app_id, authorizations, team_id, event_id, and event_time. The team comes from the presented token's installation, or affected resource or seeded team for development tokens. Incoming webhooks use their webhook or target channel team. Each logical event has a distinct ID shared across subscriber deliveries. chat.postMessage emits app_mention for installed bots mentioned as <@BOT_USER_ID> in joined channels. Subscribe to app_mention and invite the bot with conversations.invite. DMs and MPIMs use message events. Mention callbacks include api_app_id and retain message and thread identity. + Each workspace installation receives its own envelope; shared subscriptions can route by api_app_id. + Without an installation, api_app_id is A000000001. verification_token defaults to emulate-verification-token. + Inner events include event_ts; message events identify channel, group, im, or mpim in channel_type. + Issued or seeded bot tokens add stored bot identity to messages, history, and replies. + Removing an installed bot emits channel_left or group_left with actor_id and event_ts. `, ); diff --git a/skills/slack/SKILL.md b/skills/slack/SKILL.md index 010cd439..2eb2b401 100644 --- a/skills/slack/SKILL.md +++ b/skills/slack/SKILL.md @@ -701,7 +701,11 @@ Open `GET /` in the Slack emulator to inspect conversations, messages, files, vi For supported Slack writes that emit events, the emulator dispatches `event_callback` payloads to configured webhook URLs. These payloads match Slack's Events API format: -Each callback includes outer `team_id`, `event_id`, and integer Unix-seconds `event_time` alongside the inner `event`. For authenticated Web API writes, `team_id` comes from the presented Slack token's installation. Development tokens without a stored Slack record fall back to the affected channel, user, or file's team, then the seeded workspace team (or `T000000001`). Incoming webhook posts use their webhook record's team, or the target channel's team when no record matches. Each logical event gets a distinct `event_id` shared by deliveries to multiple subscribers. +Each callback includes outer `token`, `api_app_id`, `authorizations`, `team_id`, `event_id`, and integer Unix-seconds `event_time` alongside the inner `event`. For authenticated Web API writes, `team_id` comes from the presented Slack token's installation. Development tokens without a stored Slack record fall back to the affected channel, user, or file's team, then the seeded workspace team (or `T000000001`). Incoming webhook posts use their webhook record's team, or the target channel's team when no record matches. Each logical event gets a distinct `event_id` shared by deliveries to multiple subscribers. + +Callbacks identify each workspace installation with its recipient `api_app_id` and bot authorization. With multiple installed apps, each receives a separate envelope; configured webhook subscriptions are shared and can route by `api_app_id`. Without an installation, callbacks use `A000000001` and a local user authorization. The legacy `token` field defaults to `emulate-verification-token`; set `slack.verification_token` to match receivers that check it. Use `slack.signing_secret` for request signature verification. + +Every inner event includes `event_ts`. Message events include `channel_type` (`channel`, `group`, `im`, or `mpim`). Posts made with an issued or seeded bot token include the bot's `bot_id`, `app_id`, and `bot_profile` when the corresponding bot record exists. These fields persist in history, replies, and updates so handlers can identify their own messages. Removing an installed bot with `conversations.kick` or `conversations.leave` also emits `channel_left` or `group_left`, with `actor_id` and `event_ts`, for the affected app. To test bot handlers, invite an installed app's `bot_user_id` with `conversations.invite`, subscribe to `app_mention`, then post text containing `<@BOT_USER_ID>` with `chat.postMessage`. Channel mentions produce one callback per mentioned app, with outer `api_app_id`, inner `event_ts`, and the original message and thread identity. DMs and MPIMs use `message` events. @@ -719,6 +723,7 @@ To test bot handlers, invite an installed app's `bot_user_id` with `conversation - `channel_rename` / `group_rename` and matching name message subtypes on `conversations.rename` - `message` with public `channel_topic` / `channel_purpose` or private `group_topic` / `group_purpose` subtypes on topic and purpose writes - `member_joined_channel` / `member_left_channel` on invite, join, leave, and kick writes +- `channel_left` / `group_left` when an installed bot leaves or is removed - `im_created`, `im_open`, `im_close`, `im_marked`, and group open/close/marked events for DM and MPIM writes - `user_change` on profile writes - `presence_change` on presence writes