From 9019dc83d770b4d601cfdcdbfa9f170a3b7e826a Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 14 Oct 2026 11:32:00 -0500 Subject: [PATCH] Improve Slack event payloads - Complete Slack event envelopes and message fields for app routing and bot self-message filtering. - Emit installed bot departure events and cover payloads with regression tests and docs. Fixes #293 Co-authored-by: Marcus Schiesser <17126+marcusschiesser@users.noreply.github.com> --- README.md | 6 +- apps/web/content/docs/slack.mdx | 7 +- .../slack/src/__tests__/app-mentions.test.ts | 1 + .../src/__tests__/event-payloads.test.ts | 308 ++++++++++++++++++ .../slack/src/__tests__/slack-events.test.ts | 11 +- packages/@emulators/slack/src/entities.ts | 9 + packages/@emulators/slack/src/events.ts | 52 ++- packages/@emulators/slack/src/helpers.ts | 33 ++ packages/@emulators/slack/src/index.ts | 5 + packages/@emulators/slack/src/routes/chat.ts | 109 +++---- .../slack/src/routes/conversations.ts | 41 ++- packages/@emulators/slack/src/routes/files.ts | 58 ++-- packages/@emulators/slack/src/routes/pins.ts | 9 +- .../@emulators/slack/src/routes/reactions.ts | 36 +- packages/@emulators/slack/src/routes/users.ts | 32 +- .../@emulators/slack/src/routes/webhooks.ts | 24 +- packages/emulate/src/index.ts | 7 +- skills/slack/SKILL.md | 7 +- 18 files changed, 581 insertions(+), 174 deletions(-) create mode 100644 packages/@emulators/slack/src/__tests__/event-payloads.test.ts diff --git a/README.md b/README.md index 23c16f35d..ccd161d43 100644 --- a/README.md +++ b/README.md @@ -1078,7 +1078,11 @@ Modal opens and pushes require values from `/api/views.generateTriggerId`. Pass ### 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 d8332204c..0c87ba495 100644 --- a/apps/web/content/docs/slack.mdx +++ b/apps/web/content/docs/slack.mdx @@ -120,7 +120,11 @@ Modal opens and pushes require values from `/api/views.generateTriggerId`. Pass 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. @@ -137,6 +141,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/src/__tests__/app-mentions.test.ts b/packages/@emulators/slack/src/__tests__/app-mentions.test.ts index a41f23c44..a7aaa1528 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 000000000..295ebca7b --- /dev/null +++ b/packages/@emulators/slack/src/__tests__/event-payloads.test.ts @@ -0,0 +1,308 @@ +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); + 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__/slack-events.test.ts b/packages/@emulators/slack/src/__tests__/slack-events.test.ts index f26c7c3f5..d9f4ca0ba 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 ada98b496..bbcb47b99 100644 --- a/packages/@emulators/slack/src/entities.ts +++ b/packages/@emulators/slack/src/entities.ts @@ -98,6 +98,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 9c9f391e3..a7a28899f 100644 --- a/packages/@emulators/slack/src/events.ts +++ b/packages/@emulators/slack/src/events.ts @@ -1,11 +1,59 @@ import { randomUUID } from "node:crypto"; -import type { Context, Store } from "@emulators/core"; +import type { Context, Store, WebhookDispatcher } from "@emulators/core"; +import type { SlackInstallation } from "./entities.js"; +import { generateTs } from "./helpers.js"; import { getSlackStore } from "./store.js"; -export function buildSlackEventEnvelope(teamId: string, event: Record) { +export async function dispatchSlackEvent( + store: Store, + webhooks: WebhookDispatcher, + teamId: string, + event: Record, + recipient?: SlackInstallation, +) { + const ss = getSlackStore(store); + const channel = typeof event.channel === "string" ? ss.channels.findOneBy("channel_id", event.channel) : undefined; + const normalizedEvent = { + event_ts: event.type === "message" && typeof event.ts === "string" ? event.ts : generateTs(), + ...(event.type === "message" && channel + ? { channel_type: channel.is_im ? "im" : channel.is_mpim ? "mpim" : channel.is_private ? "group" : "channel" } + : {}), + ...event, + }; + const installations = recipient ? [recipient] : ss.installations.findBy("team_id", teamId); + 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 user = getSlackStore(store) + .users.all() + .find((user) => user.team_id === teamId && !user.deleted); 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), event, diff --git a/packages/@emulators/slack/src/helpers.ts b/packages/@emulators/slack/src/helpers.ts index 97853d202..a8781101b 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 b2c765433..7dfd92850 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; } const DEFAULT_SLACK_SCOPES = [ @@ -378,6 +379,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/chat.ts b/packages/@emulators/slack/src/routes/chat.ts index 81f943acb..974839d47 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"; @@ -58,12 +59,7 @@ export function chatRoutes(ctx: RouteContext): void { event: Record, teamId?: string, ) => { - await webhooks.dispatch( - type, - undefined, - buildSlackEventEnvelope(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; @@ -80,19 +76,17 @@ export function chatRoutes(ctx: RouteContext): void { continue; } - await webhooks.dispatch( - "app_mention", - undefined, + await dispatchSlackEvent( + store, + webhooks, + teamId, { - ...buildSlackEventEnvelope(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, ); } }; @@ -176,6 +170,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, channel_id: ch.channel_id, @@ -184,6 +187,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: [], @@ -205,16 +209,11 @@ export function chatRoutes(ctx: RouteContext): void { } } - await webhooks.dispatch( - "message", - undefined, - buildSlackEventEnvelope(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, { @@ -301,6 +300,10 @@ export function chatRoutes(ctx: RouteContext): void { if (!isAuthoredByUser(msg, authUser)) return slackError(c, "cant_update_message"); const updates: Partial = { ...richMessage.fields }; + if (msg.bot_profile) { + updates.bot_id = msg.bot_id; + updates.app_id = msg.app_id; + } if (hasText) { updates.text = normalizedText!.text; if (!richMessage.providedFields.includes("blocks")) updates.blocks = undefined; @@ -318,21 +321,16 @@ export function chatRoutes(ctx: RouteContext): void { edited: { user: authUserId, ts: eventTs }, })!; - await webhooks.dispatch( - "message", - undefined, - buildSlackEventEnvelope(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, @@ -369,21 +367,16 @@ export function chatRoutes(ctx: RouteContext): void { deletePinsForMessage(channel, ts); const eventTs = generateTs(); - await webhooks.dispatch( - "message", - undefined, - buildSlackEventEnvelope(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 90120f225..8454e819d 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 { getSlackStore } from "../store.js"; import { formatSlackMessage, @@ -76,12 +76,7 @@ export function conversationsRoutes(ctx: RouteContext): void { type: string, event: Record, ) => { - await webhooks.dispatch( - type, - undefined, - buildSlackEventEnvelope(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, @@ -101,16 +96,11 @@ export function conversationsRoutes(ctx: RouteContext): void { reactions: [], }); - await webhooks.dispatch( - "message", - undefined, - buildSlackEventEnvelope(resolveSlackEventTeamId(c, store, channel.team_id), { - ...formatSlackMessage(msg), - channel: channel.channel_id, - 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) => { @@ -123,12 +113,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, + ); + } }; // conversations.list diff --git a/packages/@emulators/slack/src/routes/files.ts b/packages/@emulators/slack/src/routes/files.ts index af3d648a7..b6bafc0ea 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, @@ -255,7 +255,7 @@ export function filesRoutes(ctx: RouteContext): void { }), ); ss().fileUploadSessions.update(session.id, { completed: true }); - await dispatchFileEvent(webhooks, "file_created", file, resolveSlackEventTeamId(c, store, file.team_id)); + await dispatchFileEvent(store, webhooks, "file_created", file, resolveSlackEventTeamId(c, store, file.team_id)); completedFiles.push(file); } @@ -290,23 +290,25 @@ export function filesRoutes(ctx: RouteContext): void { for (const file of updatedFiles) { const shared = updateFileShare(file, channel, msg, authUserId); messageFiles.push(shared); - await dispatchFileEvent(webhooks, "file_shared", shared, resolveSlackEventTeamId(c, store, shared.team_id), { - channel_id: channel.channel_id, - }); + await dispatchFileEvent( + store, + webhooks, + "file_shared", + shared, + resolveSlackEventTeamId(c, store, shared.team_id), + { + channel_id: channel.channel_id, + }, + ); } const updatedMessage = ss().messages.update(msg.id, { files: messageFiles })!; - await webhooks.dispatch( - "message", - undefined, - buildSlackEventEnvelope(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); @@ -413,7 +415,13 @@ export function filesRoutes(ctx: RouteContext): void { const deleted = ss().files.update(file.id, { deleted: true })!; removeFileFromMessages(deleted.file_id); - await dispatchFileEvent(webhooks, "file_deleted", deleted, resolveSlackEventTeamId(c, store, deleted.team_id)); + await dispatchFileEvent( + store, + webhooks, + "file_deleted", + deleted, + resolveSlackEventTeamId(c, store, deleted.team_id), + ); return slackOk(c, {}); }); @@ -700,21 +708,17 @@ function prettyTypeFor(filetype: string): string { } async function dispatchFileEvent( + store: RouteContext["store"], webhooks: RouteContext["webhooks"], type: "file_created" | "file_shared" | "file_deleted", file: SlackFile, teamId: string, extra: Record = {}, ) { - await webhooks.dispatch( + await dispatchSlackEvent(store, webhooks, teamId, { type, - undefined, - buildSlackEventEnvelope(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/pins.ts b/packages/@emulators/slack/src/routes/pins.ts index e8885c9ef..b231a6272 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(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 79299428c..53e978680 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(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(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 789fdbe2e..f6da90532 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(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(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 ef8493d1f..6ca821b8e 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(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 bd1470993..5a16958f6 100644 --- a/packages/emulate/src/index.ts +++ b/packages/emulate/src/index.ts @@ -112,13 +112,18 @@ Slack message limits: and successful Web API responses include message_truncated warning metadata. Slack event callbacks: - 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 057feaf40..e32bd8bc4 100644 --- a/skills/slack/SKILL.md +++ b/skills/slack/SKILL.md @@ -672,7 +672,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. @@ -689,6 +693,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