From 3f92b570832be4974223587661fed6db6528f86f Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 14 Oct 2026 12:47:17 -0500 Subject: [PATCH] Support Slack Connect fixtures - Seed external Slack users and expose shared-channel and app-mention metadata. - Preserve sharing across membership changes, with regression tests and docs. Fixes #292 Co-authored-by: Marcus Schiesser <17126+marcusschiesser@users.noreply.github.com> --- README.md | 23 +- apps/web/content/docs/slack.mdx | 23 +- packages/@emulators/slack/README.md | 23 +- .../slack/src/__tests__/slack-connect.test.ts | 294 ++++++++++++++++++ .../slack/src/__tests__/slack-coverage.ts | 18 +- packages/@emulators/slack/src/connect.ts | 12 + packages/@emulators/slack/src/entities.ts | 2 + packages/@emulators/slack/src/events.ts | 19 +- packages/@emulators/slack/src/index.ts | 7 +- packages/@emulators/slack/src/routes/apps.ts | 2 +- packages/@emulators/slack/src/routes/chat.ts | 10 +- .../slack/src/routes/conversations.ts | 14 +- packages/@emulators/slack/src/routes/files.ts | 28 +- .../slack/src/routes/interactions.ts | 2 +- packages/@emulators/slack/src/routes/pins.ts | 2 +- .../@emulators/slack/src/routes/reactions.ts | 4 +- packages/@emulators/slack/src/routes/users.ts | 5 +- .../@emulators/slack/src/routes/webhooks.ts | 2 +- packages/emulate/src/index.ts | 6 + skills/slack/SKILL.md | 23 +- 20 files changed, 480 insertions(+), 39 deletions(-) create mode 100644 packages/@emulators/slack/src/__tests__/slack-connect.test.ts create mode 100644 packages/@emulators/slack/src/connect.ts diff --git a/README.md b/README.md index 2e84acb5..be183473 100644 --- a/README.md +++ b/README.md @@ -1072,6 +1072,27 @@ Modal opens and pushes accept `trigger_id` or `interactivity_pointer` from an in - `POST /api/bots.info` - bot info - `POST /services/:teamId/:botId/:webhookId` - incoming webhook with text or rich payload fields +### Slack Connect fixtures + +Seed a user with a different `team_id` to test messages from another workspace. `users.info` and `users.list` return that user's workspace and `is_stranger` flag. Omitted user teams default to the emulated workspace. `is_stranger` defaults to `false` and can be seeded to model a user outside the app's visible shared channels. + +```yaml +slack: + users: + - name: partner + team_id: T_PARTNER + is_stranger: false + tokens: + - token: xoxp-partner + type: user + user: partner + scopes: [chat:write] +``` + +Use the seeded user token to act as the external user. Invite the user and your app's bot to a channel with `conversations.invite`, then post a message mentioning the bot. The app-mention event includes the author's `user_team` and `source_team`; the outer `team_id` identifies the receiving installation workspace selected by the token, which defaults to the emulated workspace. Ordinary message callbacks retain the author user ID, which can be resolved with `users.info`. + +A channel with external members returns `is_ext_shared: true` and `is_shared: true`; its event callbacks include `is_ext_shared_channel: true`. Seed `channels[].is_ext_shared: true` to model an already shared channel. Sharing remains set after the last external member leaves or is removed, including across saved state. Slack Connect invitation and approval flows, workspace disconnection, and per-workspace channel settings are not simulated. + ### Event retries Use `POST /_slack/simulate/event-retry` with an authenticated emulator token and JSON `{ "event_id": "Ev...", "reason": "http_timeout" }` to test event deduplication. Get the event ID from a received callback or the inspector's Events tab. The simulator resends the latest recorded delivery for that event to its original subscription, preserving the entire payload and event ID. If several subscribers received it, only the latest recipient is retried. @@ -1124,7 +1145,7 @@ To test bot handlers, invite an installed app's `bot_user_id` with `conversation Slack scope checks are relaxed by default so local tests can use simple bearer tokens. Set `slack.strict_scopes: true` in seed config to make supported Web API methods return Slack-style `missing_scope` errors with `needed` and `provided` fields. Strict mode checks `chat:write`, `channels:read`, `channels:history`, `channels:join`, `channels:manage`, `channels:write`, `groups:read`, `groups:history`, `groups:write`, `im:read`, `im:history`, `im:write`, `mpim:read`, `mpim:history`, `mpim:write`, `users:read`, `users:read.email`, `users.profile:read`, `users.profile:write`, `users:write`, `files:read`, `files:write`, `pins:read`, `pins:write`, `bookmarks:read`, `bookmarks:write`, `reactions:read`, `reactions:write`, and `team:read`. Slack lists no method-specific scopes for `views.publish`, `views.open`, `views.update`, or `views.push`, so the emulator requires auth but does not add strict-scope checks for those methods. -Current Slack limits: Slack Connect, Enterprise Grid admin APIs, Audit Logs API, SCIM, Legal Holds, Socket Mode, slash commands, shortcuts, and interaction types other than button actions and modal submissions, user groups, reminders, stars, calls, canvases, lists, functions, workflows, chat streaming, legacy `files.upload`, exact rate limiting, and paid-plan behavior are not implemented. +Current Slack limits: Slack Connect invitation and approval flows, per-workspace channel settings, Enterprise Grid admin APIs, Audit Logs API, SCIM, Legal Holds, Socket Mode, slash commands, shortcuts, and interaction types other than button actions and modal submissions, user groups, reminders, stars, calls, canvases, lists, functions, workflows, chat streaming, legacy `files.upload`, exact rate limiting, and paid-plan behavior are not implemented. ## Linear API diff --git a/apps/web/content/docs/slack.mdx b/apps/web/content/docs/slack.mdx index b9cb8067..04416ead 100644 --- a/apps/web/content/docs/slack.mdx +++ b/apps/web/content/docs/slack.mdx @@ -176,6 +176,27 @@ To test bot handlers, invite an installed app's `bot_user_id` with `conversation Set `slack.signing_secret` in seed config to sign every outbound event subscription callback, including subscriptions that already exist. Signed callbacks include `X-Slack-Request-Timestamp` and `X-Slack-Signature`. The signature is `v0=:")>`, calculated from the exact serialized request body. Configure the receiver with the same secret and verify the unparsed body. When the secret is absent or empty, callbacks are unsigned. +### Slack Connect fixtures + +Seed a user with a different `team_id` to test messages from another workspace. `users.info` and `users.list` return that user's workspace and `is_stranger` flag. Omitted user teams default to the emulated workspace. `is_stranger` defaults to `false` and can be seeded to model a user outside the app's visible shared channels. + +```yaml +slack: + users: + - name: partner + team_id: T_PARTNER + is_stranger: false + tokens: + - token: xoxp-partner + type: user + user: partner + scopes: [chat:write] +``` + +Use the seeded user token to act as the external user. Invite the user and your app's bot to a channel with `conversations.invite`, then post a message mentioning the bot. The app-mention event includes the author's `user_team` and `source_team`; the outer `team_id` identifies the receiving installation workspace selected by the token, which defaults to the emulated workspace. Ordinary message callbacks retain the author user ID, which can be resolved with `users.info`. + +A channel with external members returns `is_ext_shared: true` and `is_shared: true`; its event callbacks include `is_ext_shared_channel: true`. Seed `channels[].is_ext_shared: true` to model an already shared channel. Sharing remains set after the last external member leaves or is removed, including across saved state. Slack Connect invitation and approval flows, workspace disconnection, and per-workspace channel settings are not simulated. + ### Event retries Use `POST /_slack/simulate/event-retry` with an authenticated emulator token and JSON `{ "event_id": "Ev...", "reason": "http_timeout" }` to test event deduplication. Get the event ID from a received callback or the inspector's Events tab. The simulator resends the latest recorded delivery for that event to its original subscription, preserving the entire payload and event ID. If several subscribers received it, only the latest recipient is retried. @@ -186,4 +207,4 @@ Retries are explicit, with no automatic scheduling or three-attempt limit. Only ## Current Limits -Slack Connect, Enterprise Grid admin APIs, Audit Logs API, SCIM, Legal Holds, Socket Mode, slash commands, shortcuts, and interaction types other than button actions and modal submissions, user groups, reminders, stars, calls, canvases, lists, functions, workflows, chat streaming, legacy `files.upload`, exact rate limiting, and paid-plan behavior are not implemented. +Slack Connect invitation and approval flows, per-workspace channel settings, Enterprise Grid admin APIs, Audit Logs API, SCIM, Legal Holds, Socket Mode, slash commands, shortcuts, and interaction types other than button actions and modal submissions, user groups, reminders, stars, calls, canvases, lists, functions, workflows, chat streaming, legacy `files.upload`, exact rate limiting, and paid-plan behavior are not implemented. diff --git a/packages/@emulators/slack/README.md b/packages/@emulators/slack/README.md index 54623d4b..d384b3df 100644 --- a/packages/@emulators/slack/README.md +++ b/packages/@emulators/slack/README.md @@ -81,6 +81,27 @@ Modal opens and pushes accept `trigger_id` or `interactivity_pointer` from an in - `POST /api/bots.info` — bot info - `POST /services/:teamId/:botId/:webhookId` — incoming webhook with text or rich payload fields +### Slack Connect fixtures + +Seed a user with a different `team_id` to test messages from another workspace. `users.info` and `users.list` return that user's workspace and `is_stranger` flag. Omitted user teams default to the emulated workspace. `is_stranger` defaults to `false` and can be seeded to model a user outside the app's visible shared channels. + +```yaml +slack: + users: + - name: partner + team_id: T_PARTNER + is_stranger: false + tokens: + - token: xoxp-partner + type: user + user: partner + scopes: [chat:write] +``` + +Use the seeded user token to act as the external user. Invite the user and your app's bot to a channel with `conversations.invite`, then post a message mentioning the bot. The app-mention event includes the author's `user_team` and `source_team`; the outer `team_id` identifies the receiving installation workspace selected by the token, which defaults to the emulated workspace. Ordinary message callbacks retain the author user ID, which can be resolved with `users.info`. + +A channel with external members returns `is_ext_shared: true` and `is_shared: true`; its event callbacks include `is_ext_shared_channel: true`. Seed `channels[].is_ext_shared: true` to model an already shared channel. Sharing remains set after the last external member leaves or is removed, including across saved state. Slack Connect invitation and approval flows, workspace disconnection, and per-workspace channel settings are not simulated. + ### Event retries Use `POST /_slack/simulate/event-retry` with an authenticated emulator token and JSON `{ "event_id": "Ev...", "reason": "http_timeout" }` to test event deduplication. Get the event ID from a received callback or the inspector's Events tab. The simulator resends the latest recorded delivery for that event to its original subscription, preserving the entire payload and event ID. If several subscribers received it, only the latest recipient is retried. @@ -132,7 +153,7 @@ All Web API endpoints require `Authorization: Bearer `. Seeded OAuth apps ## Current Limits -Slack Connect, Enterprise Grid admin APIs, Audit Logs API, SCIM, Legal Holds, Socket Mode, slash commands, shortcuts, and interaction types other than button actions and modal submissions, user groups, reminders, stars, calls, canvases, lists, functions, workflows, chat streaming, legacy `files.upload`, exact rate limiting, and paid-plan behavior are not implemented. +Slack Connect invitation and approval flows, per-workspace channel settings, Enterprise Grid admin APIs, Audit Logs API, SCIM, Legal Holds, Socket Mode, slash commands, shortcuts, and interaction types other than button actions and modal submissions, user groups, reminders, stars, calls, canvases, lists, functions, workflows, chat streaming, legacy `files.upload`, exact rate limiting, and paid-plan behavior are not implemented. ## Seed Configuration diff --git a/packages/@emulators/slack/src/__tests__/slack-connect.test.ts b/packages/@emulators/slack/src/__tests__/slack-connect.test.ts new file mode 100644 index 00000000..79a5e936 --- /dev/null +++ b/packages/@emulators/slack/src/__tests__/slack-connect.test.ts @@ -0,0 +1,294 @@ +import { createHmac } from "node:crypto"; +import { WebClient } from "@slack/web-api"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { getSlackStore, seedFromConfig } from "../index.js"; +import { + authHeaders, + captureFetchRequests, + createSlackTestApp, + registerSlackEventSubscription, + slackTestBaseUrl as base, + slackTestToken, + startSlackTestEmulator, + type SlackTestApp, +} from "./helpers.js"; + +const foreignTeam = "T_EXTERNAL"; +const localTeam = "T000000001"; +const botUser = "U_CONNECT_BOT"; + +function configure(setup: SlackTestApp) { + seedFromConfig(setup.store, base, { + users: [ + { name: "partner", team_id: foreignTeam }, + { name: "colleague" }, + { name: "stranger", team_id: "T_STRANGER", is_stranger: true }, + ], + oauth_apps: [ + { + app_id: "A_CONNECT", + client_id: "connect", + client_secret: "secret", + name: "Connect Bot", + redirect_uris: [], + bot_user_id: botUser, + }, + ], + tokens: [ + { + token: "xoxp-partner", + type: "user", + user: "partner", + scopes: ["chat:write", "channels:join", "channels:write"], + }, + ], + signing_secret: "connect-signing-secret", + }); + const ss = getSlackStore(setup.store); + const foreign = ss.users.findOneBy("name", "partner")!; + setup.tokenMap.set("external-dev-token", { login: foreign.user_id, id: foreign.id, scopes: [] }); + return setup; +} + +function call(setup: SlackTestApp, method: string, body: unknown, token?: string) { + return setup.app + .request(`/api/${method}`, { + method: "POST", + headers: token ? { ...authHeaders(), Authorization: `Bearer ${token}` } : authHeaders(), + body: JSON.stringify(body), + }) + .then((response) => response.json() as Promise>); +} + +async function createSharedChannel(setup: SlackTestApp, isPrivate = false) { + const created = await call(setup, "conversations.create", { name: "partners", is_private: isPrivate }); + const channel = created.channel.id as string; + const user = getSlackStore(setup.store).users.findOneBy("name", "partner")!.user_id; + expect(await call(setup, "conversations.invite", { channel, users: `${user},${botUser}` })).toMatchObject({ + ok: true, + }); + return { channel, user }; +} + +afterEach(() => vi.unstubAllGlobals()); + +describe("Slack Connect fixtures", () => { + it("returns each user's workspace and stranger flag through user reads", async () => { + const setup = configure(createSlackTestApp()); + const ss = getSlackStore(setup.store); + for (const [name, team_id, is_stranger] of [ + ["partner", foreignTeam, false], + ["colleague", localTeam, false], + ["stranger", "T_STRANGER", true], + ] as const) { + const user = ss.users.findOneBy("name", name)!; + expect(await call(setup, "users.info", { user: user.user_id })).toMatchObject({ + user: { id: user.user_id, team_id, is_stranger }, + }); + expect((await call(setup, "users.list", {})).members).toEqual( + expect.arrayContaining([expect.objectContaining({ id: user.user_id, team_id, is_stranger })]), + ); + } + }); + + it("recognizes seeded external members and explicit shared channels", async () => { + const setup = configure(createSlackTestApp()); + seedFromConfig(setup.store, base, { channels: [{ name: "seeded-partners" }] }); + const seeded = getSlackStore(setup.store).channels.findOneBy("name", "seeded-partners")!; + expect(await call(setup, "conversations.info", { channel: seeded.channel_id })).toMatchObject({ + channel: { is_ext_shared: true, is_shared: true }, + }); + + const local = createSlackTestApp(); + seedFromConfig(local.store, base, { channels: [{ name: "shared-fixture", is_ext_shared: true }] }); + const shared = getSlackStore(local.store).channels.findOneBy("name", "shared-fixture")!; + expect(await call(local, "conversations.info", { channel: shared.channel_id })).toMatchObject({ + channel: { is_ext_shared: true, is_shared: true }, + }); + expect(await call(local, "conversations.info", { channel: "C000000001" })).toMatchObject({ + channel: { is_ext_shared: false, is_shared: false }, + }); + }); + + it.each([false, true])( + "marks invited external members in a private=%s channel and retains sharing after a kick", + async (isPrivate) => { + const setup = configure(createSlackTestApp()); + const { channel, user } = await createSharedChannel(setup, isPrivate); + const info = await call(setup, "conversations.info", { channel }); + expect(info.channel).toMatchObject({ is_private: isPrivate, is_ext_shared: true, is_shared: true }); + expect( + (await call(setup, "conversations.list", { types: isPrivate ? "private_channel" : "public_channel" })).channels, + ).toEqual(expect.arrayContaining([expect.objectContaining({ id: channel, is_ext_shared: true })])); + expect(await call(setup, "conversations.kick", { channel, user })).toMatchObject({ ok: true }); + expect((await call(setup, "conversations.members", { channel })).members).not.toContain(user); + expect((await call(setup, "conversations.info", { channel })).channel.is_ext_shared).toBe(true); + }, + ); + + it("marks an external user's join and retains sharing after they leave", async () => { + const setup = configure(createSlackTestApp()); + const { channel } = await call(setup, "conversations.create", { name: "join-test" }); + expect(await call(setup, "conversations.join", { channel: channel.id }, "xoxp-partner")).toMatchObject({ + channel: { is_ext_shared: true }, + }); + expect(await call(setup, "conversations.leave", { channel: channel.id }, "xoxp-partner")).toMatchObject({ + ok: true, + }); + expect((await call(setup, "conversations.info", { channel: channel.id })).channel.is_ext_shared).toBe(true); + }); + + it("does not mark a channel shared when an invite is rejected", async () => { + const setup = configure(createSlackTestApp()); + const { channel } = await call(setup, "conversations.create", { name: "atomic-invite" }); + const foreign = getSlackStore(setup.store).users.findOneBy("name", "partner")!; + expect( + await call(setup, "conversations.invite", { channel: channel.id, users: `${foreign.user_id},U_MISSING` }), + ).toMatchObject({ ok: false }); + expect((await call(setup, "conversations.info", { channel: channel.id })).channel.is_ext_shared).toBe(false); + }); + + it.each(["xoxp-partner", "external-dev-token"])( + "distinguishes a foreign author from the receiving workspace with %s", + async (token) => { + const setup = configure(createSlackTestApp()); + const { channel, user } = await createSharedChannel(setup); + const parent = await call(setup, "chat.postMessage", { channel, text: "Local thread" }); + const capture = captureFetchRequests(); + registerSlackEventSubscription(setup.webhooks, ["message", "app_mention"]); + const posted = await call( + setup, + "chat.postMessage", + { channel, text: `<@${botUser}> help`, thread_ts: parent.ts }, + token, + ); + expect(posted.ok).toBe(true); + expect(capture.jsonBodies()).toEqual([ + expect.objectContaining({ + team_id: localTeam, + is_ext_shared_channel: true, + event: expect.objectContaining({ type: "message", user, ts: posted.ts, thread_ts: parent.ts }), + }), + expect.objectContaining({ + team_id: localTeam, + is_ext_shared_channel: true, + api_app_id: "A_CONNECT", + event: expect.objectContaining({ + type: "app_mention", + user, + user_team: foreignTeam, + source_team: foreignTeam, + ts: posted.ts, + thread_ts: parent.ts, + }), + }), + ]); + const mention = capture.requests[1]!.init; + const headers = mention.headers as Record; + expect(headers["X-Slack-Signature"]).toBe( + `v0=${createHmac("sha256", "connect-signing-secret").update(`v0:${headers["X-Slack-Request-Timestamp"]}:${mention.body}`).digest("hex")}`, + ); + expect((await call(setup, "conversations.replies", { channel, ts: parent.ts })).messages).toEqual( + expect.arrayContaining([expect.objectContaining({ user, ts: posted.ts })]), + ); + }, + ); + + it("keeps local authors local in shared channels", async () => { + const setup = configure(createSlackTestApp()); + const { channel } = await createSharedChannel(setup); + const capture = captureFetchRequests(); + registerSlackEventSubscription(setup.webhooks, ["app_mention"]); + await call(setup, "chat.postMessage", { channel, text: `<@${botUser}> local author` }); + const [payload] = capture.jsonBodies() as Array>; + expect(payload).toMatchObject({ team_id: localTeam, is_ext_shared_channel: true, event: { user: "U000000001" } }); + expect(payload!.event).not.toHaveProperty("user_team"); + }); + + it("marks updates, reactions, and deletions in shared channels", async () => { + const setup = configure(createSlackTestApp()); + const { channel } = await createSharedChannel(setup); + const capture = captureFetchRequests(); + registerSlackEventSubscription(setup.webhooks); + const posted = await call(setup, "chat.postMessage", { channel, text: "before" }); + await call(setup, "chat.update", { channel, ts: posted.ts, text: "after" }); + await call(setup, "reactions.add", { channel, timestamp: posted.ts, name: "wave" }); + await call(setup, "reactions.remove", { channel, timestamp: posted.ts, name: "wave" }); + await call(setup, "chat.delete", { channel, ts: posted.ts }); + expect(capture.jsonBodies()).toHaveLength(5); + for (const payload of capture.jsonBodies()) + expect(payload).toMatchObject({ team_id: localTeam, is_ext_shared_channel: true }); + }); + + it("keeps private channel access restricted for an uninvited external user", async () => { + const setup = configure(createSlackTestApp()); + const { channel } = await call(setup, "conversations.create", { name: "private-local", is_private: true }); + expect(await call(setup, "conversations.info", { channel: channel.id }, "xoxp-partner")).toMatchObject({ + ok: false, + error: "not_in_channel", + }); + expect( + await call(setup, "chat.postMessage", { channel: channel.id, text: "No access" }, "xoxp-partner"), + ).toMatchObject({ ok: false, error: "not_in_channel" }); + expect( + (await call(setup, "conversations.list", { types: "private_channel" }, "xoxp-partner")).channels, + ).not.toEqual(expect.arrayContaining([expect.objectContaining({ id: channel.id })])); + }); + + it("reads older snapshots without Connect fields using local defaults", async () => { + const setup = createSlackTestApp(); + setup.store.restore(JSON.parse(JSON.stringify(setup.store.snapshot()))); + expect((await call(setup, "users.info", { user: "U000000001" })).user).toMatchObject({ + team_id: localTeam, + is_stranger: false, + }); + expect((await call(setup, "conversations.info", { channel: "C000000001" })).channel).toMatchObject({ + is_shared: false, + is_ext_shared: false, + }); + const capture = captureFetchRequests(); + registerSlackEventSubscription(setup.webhooks); + await call(setup, "chat.postMessage", { channel: "C000000001", text: "Local message" }); + expect(capture.jsonBodies()[0]).toMatchObject({ team_id: localTeam, is_ext_shared_channel: false }); + }); + + it("preserves shared state across snapshots after the external member leaves", async () => { + const setup = configure(createSlackTestApp()); + const { channel, user } = await createSharedChannel(setup); + await call(setup, "conversations.kick", { channel, user }); + const snapshot = JSON.parse(JSON.stringify(setup.store.snapshot())); + const restored = createSlackTestApp(); + restored.store.restore(snapshot); + expect((await call(restored, "conversations.info", { channel })).channel.is_ext_shared).toBe(true); + expect((await call(restored, "users.info", { user })).user.team_id).toBe(foreignTeam); + }); + + it("supports the Slack SDK's user and conversation reads over HTTP", async () => { + const emulator = await startSlackTestEmulator((setup) => { + configure(setup); + }); + try { + const client = new WebClient(slackTestToken, { slackApiUrl: `${emulator.url}/api/` }); + const foreign = getSlackStore(emulator.store).users.findOneBy("name", "partner")!; + const created = await client.conversations.create({ name: "sdk-connect" }); + const channel = created.channel!.id!; + await client.conversations.invite({ channel, users: foreign.user_id }); + expect((await client.users.info({ user: foreign.user_id })).user).toMatchObject({ + team_id: foreignTeam, + is_stranger: false, + }); + expect((await client.users.list({})).members).toEqual( + expect.arrayContaining([expect.objectContaining({ id: foreign.user_id, team_id: foreignTeam })]), + ); + expect((await client.conversations.info({ channel })).channel).toMatchObject({ + is_ext_shared: true, + is_shared: true, + }); + expect((await client.conversations.list()).channels).toEqual( + expect.arrayContaining([expect.objectContaining({ id: channel, is_ext_shared: true })]), + ); + } finally { + await emulator.close(); + } + }); +}); diff --git a/packages/@emulators/slack/src/__tests__/slack-coverage.ts b/packages/@emulators/slack/src/__tests__/slack-coverage.ts index 9a619124..11cdff9c 100644 --- a/packages/@emulators/slack/src/__tests__/slack-coverage.ts +++ b/packages/@emulators/slack/src/__tests__/slack-coverage.ts @@ -41,7 +41,7 @@ export const slackCoverageMatrix: SlackCoverageEntry[] = [ method: "chat.postMessage", route: "POST /api/chat.postMessage", status: "partial", - testedBy: ["slack.test.ts", "slack-sdk.test.ts", "slack-events.test.ts"], + testedBy: ["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.", }, @@ -117,7 +117,7 @@ export const slackCoverageMatrix: SlackCoverageEntry[] = [ method: "conversations.list", route: "POST /api/conversations.list", status: "partial", - testedBy: ["slack.test.ts", "slack-sdk.test.ts"], + testedBy: ["slack-connect.test.ts", "slack.test.ts", "slack-sdk.test.ts"], notes: "Lists conversations with simple cursor pagination, exclude_archived, and basic public/private/IM/MPIM type filtering.", }, @@ -126,7 +126,7 @@ export const slackCoverageMatrix: SlackCoverageEntry[] = [ method: "conversations.info", route: "POST /api/conversations.info", status: "supported", - testedBy: ["slack.test.ts", "slack-sdk.test.ts"], + testedBy: ["slack-connect.test.ts", "slack.test.ts", "slack-sdk.test.ts"], notes: "Returns seeded channel metadata.", }, { @@ -203,7 +203,7 @@ export const slackCoverageMatrix: SlackCoverageEntry[] = [ method: "conversations.join", route: "POST /api/conversations.join", status: "partial", - testedBy: ["slack.test.ts", "slack-sdk.test.ts"], + testedBy: ["slack-connect.test.ts", "slack.test.ts", "slack-sdk.test.ts"], notes: "Adds the auth user to channel membership.", }, { @@ -211,7 +211,7 @@ export const slackCoverageMatrix: SlackCoverageEntry[] = [ method: "conversations.leave", route: "POST /api/conversations.leave", status: "partial", - testedBy: ["slack.test.ts", "slack-sdk.test.ts"], + testedBy: ["slack-connect.test.ts", "slack.test.ts", "slack-sdk.test.ts"], notes: "Removes the auth user from channel membership.", }, { @@ -227,7 +227,7 @@ export const slackCoverageMatrix: SlackCoverageEntry[] = [ method: "conversations.invite", route: "POST /api/conversations.invite", status: "partial", - testedBy: ["slack.test.ts", "slack-sdk.test.ts", "slack-events.test.ts"], + testedBy: ["slack-connect.test.ts", "slack.test.ts", "slack-sdk.test.ts", "slack-events.test.ts"], notes: "Adds users to public/private channels and MPIMs, with basic membership errors and member_joined_channel events.", }, @@ -236,7 +236,7 @@ export const slackCoverageMatrix: SlackCoverageEntry[] = [ method: "conversations.kick", route: "POST /api/conversations.kick", status: "partial", - testedBy: ["slack.test.ts", "slack-sdk.test.ts", "slack-events.test.ts"], + 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.", }, { @@ -268,7 +268,7 @@ export const slackCoverageMatrix: SlackCoverageEntry[] = [ method: "users.list", route: "POST /api/users.list", status: "partial", - testedBy: ["slack.test.ts", "slack-sdk.test.ts"], + testedBy: ["slack-connect.test.ts", "slack.test.ts", "slack-sdk.test.ts"], notes: "Lists active users with simple cursor pagination.", }, { @@ -276,7 +276,7 @@ export const slackCoverageMatrix: SlackCoverageEntry[] = [ method: "users.info", route: "POST /api/users.info", status: "supported", - testedBy: ["slack.test.ts", "slack-sdk.test.ts"], + testedBy: ["slack-connect.test.ts", "slack.test.ts", "slack-sdk.test.ts"], notes: "Returns seeded user profile data.", }, { diff --git a/packages/@emulators/slack/src/connect.ts b/packages/@emulators/slack/src/connect.ts new file mode 100644 index 00000000..32d92742 --- /dev/null +++ b/packages/@emulators/slack/src/connect.ts @@ -0,0 +1,12 @@ +import type { Store } from "@emulators/core"; +import type { SlackChannel } from "./entities.js"; +import { getSlackStore } from "./store.js"; + +export function isSlackConnectChannel(store: Store, channel: SlackChannel): boolean { + if (channel.is_ext_shared) return true; + const users = getSlackStore(store).users; + return channel.members.some((member) => { + const user = users.findOneBy("user_id", member) ?? users.findOneBy("name", member); + return user !== undefined && user.team_id !== channel.team_id; + }); +} diff --git a/packages/@emulators/slack/src/entities.ts b/packages/@emulators/slack/src/entities.ts index 6ac7151d..41b89377 100644 --- a/packages/@emulators/slack/src/entities.ts +++ b/packages/@emulators/slack/src/entities.ts @@ -41,6 +41,7 @@ export type SlackManualPresence = "auto" | "away"; export interface SlackUser extends Entity { user_id: string; team_id: string; + is_stranger?: boolean; name: string; real_name: string; email: string; @@ -57,6 +58,7 @@ export interface SlackUser extends Entity { export interface SlackChannel extends Entity { channel_id: string; team_id: string; + is_ext_shared?: boolean; name: string; is_channel: boolean; is_private: boolean; diff --git a/packages/@emulators/slack/src/events.ts b/packages/@emulators/slack/src/events.ts index 9c9f391e..0f15e685 100644 --- a/packages/@emulators/slack/src/events.ts +++ b/packages/@emulators/slack/src/events.ts @@ -1,14 +1,29 @@ import { randomUUID } from "node:crypto"; import type { Context, Store } from "@emulators/core"; +import { isSlackConnectChannel } from "./connect.js"; import { getSlackStore } from "./store.js"; -export function buildSlackEventEnvelope(teamId: string, event: Record) { +export function buildSlackEventEnvelope(store: Store, teamId: string, event: Record) { + const ss = getSlackStore(store); + const channelId = + typeof event.channel === "string" + ? event.channel + : ((event.channel as { id?: string } | undefined)?.id ?? + (event.item as { channel?: string } | undefined)?.channel ?? + (typeof event.channel_id === "string" ? event.channel_id : undefined)); + const channel = channelId ? ss.channels.findOneBy("channel_id", channelId) : undefined; + const shared = channel ? isSlackConnectChannel(store, channel) : false; + const author = typeof event.user === "string" ? ss.users.findOneBy("user_id", event.user) : undefined; return { type: "event_callback" as const, team_id: teamId, event_id: `Ev${randomUUID().replaceAll("-", "")}`, event_time: Math.floor(Date.now() / 1000), - event, + ...(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, }; } diff --git a/packages/@emulators/slack/src/index.ts b/packages/@emulators/slack/src/index.ts index 425ef5af..33f744cd 100644 --- a/packages/@emulators/slack/src/index.ts +++ b/packages/@emulators/slack/src/index.ts @@ -33,6 +33,8 @@ export interface SlackSeedConfig { }; users?: Array<{ name: string; + team_id?: string; + is_stranger?: boolean; real_name?: string; email?: string; is_admin?: boolean; @@ -41,6 +43,7 @@ export interface SlackSeedConfig { }>; channels?: Array<{ name: string; + is_ext_shared?: boolean; topic?: string; purpose?: string; is_private?: boolean; @@ -232,7 +235,8 @@ export function seedFromConfig(store: Store, _baseUrl: string, config: SlackSeed }); ss.users.insert({ user_id: userId, - team_id: teamId, + team_id: u.team_id ?? teamId, + is_stranger: u.is_stranger ?? false, name: u.name, real_name: profile.real_name, email: profile.email, @@ -261,6 +265,7 @@ export function seedFromConfig(store: Store, _baseUrl: string, config: SlackSeed channel_id: generateSlackId("C"), team_id: teamId, name: ch.name, + is_ext_shared: ch.is_ext_shared ?? false, is_channel: !isPrivate, is_private: isPrivate, is_archived: false, diff --git a/packages/@emulators/slack/src/routes/apps.ts b/packages/@emulators/slack/src/routes/apps.ts index 2e13e2a2..c8a65120 100644 --- a/packages/@emulators/slack/src/routes/apps.ts +++ b/packages/@emulators/slack/src/routes/apps.ts @@ -71,7 +71,7 @@ export function appsRoutes({ app, store, webhooks, tokenMap }: RouteContext): vo String(event.type), undefined, { - ...buildSlackEventEnvelope(installation.team_id, { event_ts: generateTs(), ...event }), + ...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: [ diff --git a/packages/@emulators/slack/src/routes/chat.ts b/packages/@emulators/slack/src/routes/chat.ts index df80f028..187e1f0d 100644 --- a/packages/@emulators/slack/src/routes/chat.ts +++ b/packages/@emulators/slack/src/routes/chat.ts @@ -70,7 +70,7 @@ export function chatRoutes(ctx: RouteContext): void { await webhooks.dispatch( type, undefined, - buildSlackEventEnvelope(resolveSlackEventTeamId(c, store, teamId), { type, ...event }), + buildSlackEventEnvelope(store, resolveSlackEventTeamId(c, store, teamId), { type, ...event }), "slack", ); }; @@ -93,7 +93,7 @@ export function chatRoutes(ctx: RouteContext): void { "app_mention", undefined, { - ...buildSlackEventEnvelope(teamId, { + ...buildSlackEventEnvelope(store, teamId, { ...formatSlackMessage(message), type: "app_mention", channel: channel.channel_id, @@ -218,7 +218,7 @@ export function chatRoutes(ctx: RouteContext): void { await webhooks.dispatch( "message", undefined, - buildSlackEventEnvelope(resolveSlackEventTeamId(c, store, ch.team_id), { + buildSlackEventEnvelope(store, resolveSlackEventTeamId(c, store, ch.team_id), { ...formatSlackMessage(msg), type: "message", channel: ch.channel_id, @@ -332,7 +332,7 @@ export function chatRoutes(ctx: RouteContext): void { await webhooks.dispatch( "message", undefined, - buildSlackEventEnvelope(resolveSlackEventTeamId(c, store, ch?.team_id), { + buildSlackEventEnvelope(store, resolveSlackEventTeamId(c, store, ch?.team_id), { type: "message", subtype: "message_changed", hidden: true, @@ -383,7 +383,7 @@ export function chatRoutes(ctx: RouteContext): void { await webhooks.dispatch( "message", undefined, - buildSlackEventEnvelope(resolveSlackEventTeamId(c, store, ch?.team_id), { + buildSlackEventEnvelope(store, resolveSlackEventTeamId(c, store, ch?.team_id), { type: "message", subtype: "message_deleted", hidden: true, diff --git a/packages/@emulators/slack/src/routes/conversations.ts b/packages/@emulators/slack/src/routes/conversations.ts index 90120f22..42bbfcdf 100644 --- a/packages/@emulators/slack/src/routes/conversations.ts +++ b/packages/@emulators/slack/src/routes/conversations.ts @@ -1,6 +1,7 @@ import type { Context, RouteContext } from "@emulators/core"; import type { SlackChannel, SlackFile, SlackFileShare, SlackMessage, SlackUser } from "../entities.js"; import { buildSlackEventEnvelope, resolveSlackEventTeamId } from "../events.js"; +import { isSlackConnectChannel } from "../connect.js"; import { getSlackStore } from "../store.js"; import { formatSlackMessage, @@ -21,6 +22,10 @@ import { export function conversationsRoutes(ctx: RouteContext): void { const { app, store, webhooks } = ctx; const ss = () => getSlackStore(store); + const formatChannel = (channel: SlackChannel, viewer?: string, viewerName?: string) => { + const shared = isSlackConnectChannel(store, channel); + return { ...formatConversation(channel, viewer, viewerName), is_ext_shared: shared, is_shared: shared }; + }; const getAuthSlackUser = (authUser: { login: string }) => ss().users.findOneBy("user_id", authUser.login) ?? ss().users.findOneBy("name", authUser.login); const getAuthUserId = (authUser: { login: string }) => getAuthSlackUser(authUser)?.user_id ?? authUser.login; @@ -79,7 +84,7 @@ export function conversationsRoutes(ctx: RouteContext): void { await webhooks.dispatch( type, undefined, - buildSlackEventEnvelope(resolveSlackEventTeamId(c, store, channel.team_id), { type, ...event }), + buildSlackEventEnvelope(store, resolveSlackEventTeamId(c, store, channel.team_id), { type, ...event }), "slack", ); }; @@ -104,7 +109,7 @@ export function conversationsRoutes(ctx: RouteContext): void { await webhooks.dispatch( "message", undefined, - buildSlackEventEnvelope(resolveSlackEventTeamId(c, store, channel.team_id), { + buildSlackEventEnvelope(store, resolveSlackEventTeamId(c, store, channel.team_id), { ...formatSlackMessage(msg), channel: channel.channel_id, event_ts: msg.ts, @@ -560,6 +565,7 @@ export function conversationsRoutes(ctx: RouteContext): void { const updated = ss().channels.update(ch.id, { members: updatedMembers, + is_ext_shared: isSlackConnectChannel(store, ch), num_members: updatedMembers.length, })!; await dispatchMemberLeft(c, updated, authUserId); @@ -612,6 +618,7 @@ export function conversationsRoutes(ctx: RouteContext): void { const updatedMembers = [...ch.members, ...validUsers]; const updated = ss().channels.update(ch.id, { members: updatedMembers, + is_ext_shared: isSlackConnectChannel(store, ch), num_members: updatedMembers.length, })!; @@ -654,6 +661,7 @@ export function conversationsRoutes(ctx: RouteContext): void { const updatedMembers = ch.members.filter((member) => !targetAliases.has(member)); const updated = ss().channels.update(ch.id, { members: updatedMembers, + is_ext_shared: isSlackConnectChannel(store, ch), num_members: updatedMembers.length, })!; await dispatchMemberLeft(c, updated, user); @@ -832,7 +840,7 @@ export function conversationsRoutes(ctx: RouteContext): void { }); } -function formatChannel(ch: SlackChannel, viewer?: string, viewerName?: string) { +function formatConversation(ch: SlackChannel, viewer?: string, viewerName?: string) { const imUser = ch.is_im && viewer ? ch.members.find((member) => member !== viewer) : ch.user; const isMember = viewer ? ch.members.includes(viewer) || (viewerName !== undefined && ch.members.includes(viewerName)) diff --git a/packages/@emulators/slack/src/routes/files.ts b/packages/@emulators/slack/src/routes/files.ts index af3d648a..73c84100 100644 --- a/packages/@emulators/slack/src/routes/files.ts +++ b/packages/@emulators/slack/src/routes/files.ts @@ -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,16 +290,23 @@ 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), { + buildSlackEventEnvelope(store, resolveSlackEventTeamId(c, store, channel.team_id), { ...formatSlackMessage(updatedMessage), type: "message", subtype: "file_share", @@ -413,7 +420,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,6 +713,7 @@ function prettyTypeFor(filetype: string): string { } async function dispatchFileEvent( + store: RouteContext["store"], webhooks: RouteContext["webhooks"], type: "file_created" | "file_shared" | "file_deleted", file: SlackFile, @@ -709,7 +723,7 @@ async function dispatchFileEvent( await webhooks.dispatch( type, undefined, - buildSlackEventEnvelope(teamId, { + buildSlackEventEnvelope(store, teamId, { type, file_id: file.file_id, file: formatSlackFile(file), diff --git a/packages/@emulators/slack/src/routes/interactions.ts b/packages/@emulators/slack/src/routes/interactions.ts index b025c04c..eea228b3 100644 --- a/packages/@emulators/slack/src/routes/interactions.ts +++ b/packages/@emulators/slack/src/routes/interactions.ts @@ -386,7 +386,7 @@ export function interactionRoutes({ app, store, baseUrl, webhooks }: RouteContex await webhooks.dispatch( "message", undefined, - buildSlackEventEnvelope(teamId, { event_ts: generateTs(), ...event }), + buildSlackEventEnvelope(store, teamId, { event_ts: generateTs(), ...event }), "slack", ); } diff --git a/packages/@emulators/slack/src/routes/pins.ts b/packages/@emulators/slack/src/routes/pins.ts index e8885c9e..4d121eed 100644 --- a/packages/@emulators/slack/src/routes/pins.ts +++ b/packages/@emulators/slack/src/routes/pins.ts @@ -170,7 +170,7 @@ export function pinsRoutes(ctx: RouteContext): void { await webhooks.dispatch( type, undefined, - buildSlackEventEnvelope(resolveSlackEventTeamId(c, store, channel.team_id), { type, ...event }), + buildSlackEventEnvelope(store, resolveSlackEventTeamId(c, store, channel.team_id), { type, ...event }), "slack", ); } diff --git a/packages/@emulators/slack/src/routes/reactions.ts b/packages/@emulators/slack/src/routes/reactions.ts index 79299428..c0d79cb7 100644 --- a/packages/@emulators/slack/src/routes/reactions.ts +++ b/packages/@emulators/slack/src/routes/reactions.ts @@ -63,7 +63,7 @@ export function reactionsRoutes(ctx: RouteContext): void { await webhooks.dispatch( "reaction_added", undefined, - buildSlackEventEnvelope(resolveSlackEventTeamId(c, store, ch?.team_id), { + buildSlackEventEnvelope(store, resolveSlackEventTeamId(c, store, ch?.team_id), { type: "reaction_added", user: authUserId, reaction: name, @@ -114,7 +114,7 @@ export function reactionsRoutes(ctx: RouteContext): void { await webhooks.dispatch( "reaction_removed", undefined, - buildSlackEventEnvelope(resolveSlackEventTeamId(c, store, ch?.team_id), { + buildSlackEventEnvelope(store, resolveSlackEventTeamId(c, store, ch?.team_id), { type: "reaction_removed", user: authUserId, reaction: name, diff --git a/packages/@emulators/slack/src/routes/users.ts b/packages/@emulators/slack/src/routes/users.ts index 789fdbe2..8b156cad 100644 --- a/packages/@emulators/slack/src/routes/users.ts +++ b/packages/@emulators/slack/src/routes/users.ts @@ -120,7 +120,7 @@ export function usersRoutes(ctx: RouteContext): void { await webhooks.dispatch( "user_change", undefined, - buildSlackEventEnvelope(resolveSlackEventTeamId(c, store, updated.team_id), { + buildSlackEventEnvelope(store, resolveSlackEventTeamId(c, store, updated.team_id), { type: "user_change", user: formatUser(updated), cache_ts: Number(generateTs().replace(".", "")), @@ -186,7 +186,7 @@ export function usersRoutes(ctx: RouteContext): void { await webhooks.dispatch( "presence_change", undefined, - buildSlackEventEnvelope(resolveSlackEventTeamId(c, store, updated.team_id), { + buildSlackEventEnvelope(store, resolveSlackEventTeamId(c, store, updated.team_id), { type: "presence_change", user: updated.user_id, presence: nextPresence, @@ -214,6 +214,7 @@ function formatUser(u: SlackUser, includeEmail = true) { return { id: u.user_id, team_id: u.team_id, + is_stranger: u.is_stranger ?? false, name: u.name, real_name: u.real_name, is_admin: u.is_admin, diff --git a/packages/@emulators/slack/src/routes/webhooks.ts b/packages/@emulators/slack/src/routes/webhooks.ts index ef8493d1..e5c81e01 100644 --- a/packages/@emulators/slack/src/routes/webhooks.ts +++ b/packages/@emulators/slack/src/routes/webhooks.ts @@ -101,7 +101,7 @@ export function webhookRoutes(ctx: RouteContext): void { await webhooks.dispatch( "message", undefined, - buildSlackEventEnvelope(webhook?.team_id ?? targetChannel.team_id, { + buildSlackEventEnvelope(store, webhook?.team_id ?? targetChannel.team_id, { ...eventMessage, type: "message", subtype: "bot_message", diff --git a/packages/emulate/src/index.ts b/packages/emulate/src/index.ts index d1ea45fe..c78a8961 100644 --- a/packages/emulate/src/index.ts +++ b/packages/emulate/src/index.ts @@ -126,6 +126,12 @@ Slack message limits: Slack text fields are limited to 40,000 Unicode characters. Longer text is truncated safely, and successful Web API responses include message_truncated warning metadata. +Slack Connect fixtures: + Seed slack.users[].team_id to model an external author; is_stranger is an optional fixture flag. + Channels with external members report is_ext_shared and is_shared; events include is_ext_shared_channel. + App mentions carry the external author's user_team/source_team while the envelope keeps the receiving team. + Seed slack.channels[].is_ext_shared for existing shares. Invitation/approval and workspace unlink flows are not simulated. + 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. diff --git a/skills/slack/SKILL.md b/skills/slack/SKILL.md index 7d2632ff..800dec05 100644 --- a/skills/slack/SKILL.md +++ b/skills/slack/SKILL.md @@ -727,6 +727,27 @@ To test bot handlers, invite an installed app's `bot_user_id` with `conversation When `slack.signing_secret` is configured, every existing outbound event subscription callback includes `X-Slack-Request-Timestamp` and `X-Slack-Signature`. The signature is `v0=:")>`, using the exact serialized callback body. Configure the receiver with the same secret and verify the unparsed request body. With an absent or empty secret, callbacks are unsigned. +### Slack Connect fixtures + +Seed a user with a different `team_id` to test messages from another workspace. `users.info` and `users.list` return that user's workspace and `is_stranger` flag. Omitted user teams default to the emulated workspace. `is_stranger` defaults to `false` and can be seeded to model a user outside the app's visible shared channels. + +```yaml +slack: + users: + - name: partner + team_id: T_PARTNER + is_stranger: false + tokens: + - token: xoxp-partner + type: user + user: partner + scopes: [chat:write] +``` + +Use the seeded user token to act as the external user. Invite the user and your app's bot to a channel with `conversations.invite`, then post a message mentioning the bot. The app-mention event includes the author's `user_team` and `source_team`; the outer `team_id` identifies the receiving installation workspace selected by the token, which defaults to the emulated workspace. Ordinary message callbacks retain the author user ID, which can be resolved with `users.info`. + +A channel with external members returns `is_ext_shared: true` and `is_shared: true`; its event callbacks include `is_ext_shared_channel: true`. Seed `channels[].is_ext_shared: true` to model an already shared channel. Sharing remains set after the last external member leaves or is removed, including across saved state. Slack Connect invitation and approval flows, workspace disconnection, and per-workspace channel settings are not simulated. + ### Event retries Use `POST /_slack/simulate/event-retry` with an authenticated emulator token and JSON `{ "event_id": "Ev...", "reason": "http_timeout" }` to test event deduplication. Get the event ID from a received callback or the inspector's Events tab. The simulator resends the latest recorded delivery for that event to its original subscription, preserving the entire payload and event ID. If several subscribers received it, only the latest recipient is retried. @@ -737,7 +758,7 @@ Retries are explicit, with no automatic scheduling or three-attempt limit. Only ## Current Limits -Slack Connect, Enterprise Grid admin APIs, Audit Logs API, SCIM, Legal Holds, Socket Mode, slash commands, shortcuts, and interaction types other than button actions and modal submissions, user groups, reminders, stars, calls, canvases, lists, functions, workflows, chat streaming, legacy `files.upload`, exact rate limiting, and paid-plan behavior are not implemented. +Slack Connect invitation and approval flows, per-workspace channel settings, Enterprise Grid admin APIs, Audit Logs API, SCIM, Legal Holds, Socket Mode, slash commands, shortcuts, and interaction types other than button actions and modal submissions, user groups, reminders, stars, calls, canvases, lists, functions, workflows, chat streaming, legacy `files.upload`, exact rate limiting, and paid-plan behavior are not implemented. ## Common Patterns