diff --git a/.changeset/slack-socket-mode.md b/.changeset/slack-socket-mode.md new file mode 100644 index 00000000..9815a63e --- /dev/null +++ b/.changeset/slack-socket-mode.md @@ -0,0 +1,5 @@ +--- +"@chat-adapter/slack": minor +--- + +Add Socket Mode support for environments behind firewalls that can't expose public HTTP endpoints diff --git a/examples/nextjs-chat/src/app/api/slack/socket-mode/route.ts b/examples/nextjs-chat/src/app/api/slack/socket-mode/route.ts new file mode 100644 index 00000000..9963e6f1 --- /dev/null +++ b/examples/nextjs-chat/src/app/api/slack/socket-mode/route.ts @@ -0,0 +1,92 @@ +import { after } from "next/server"; +import { bot } from "@/lib/bot"; +import { createPersistentListener } from "@/lib/persistent-listener"; + +export const maxDuration = 800; + +// Default listener duration: 10 minutes +const DEFAULT_DURATION_MS = 600 * 1000; + +/** + * Persistent listener for Slack Socket Mode. + * Handles cross-instance coordination via Redis pub/sub. + */ +const slackSocketMode = createPersistentListener({ + name: "slack-socket-mode", + redisUrl: process.env.REDIS_URL, + defaultDurationMs: DEFAULT_DURATION_MS, + maxDurationMs: DEFAULT_DURATION_MS, +}); + +/** + * Start the Slack Socket Mode WebSocket listener. + * + * This endpoint is invoked by a Vercel cron job every 9 minutes to maintain + * continuous Socket Mode connectivity. Events are acked immediately and + * forwarded via HTTP POST to the existing webhook endpoint. + * + * Security: Requires CRON_SECRET validation. + * + * Usage: GET /api/slack/socket-mode + * Optional query param: ?duration=600000 (milliseconds, max 600000) + */ +export async function GET(request: Request): Promise { + const cronSecret = process.env.CRON_SECRET; + if (!cronSecret) { + console.error("[slack-socket-mode] CRON_SECRET not configured"); + return new Response("CRON_SECRET not configured", { status: 500 }); + } + const authHeader = request.headers.get("authorization"); + if (authHeader !== `Bearer ${cronSecret}`) { + console.log("[slack-socket-mode] Unauthorized: invalid CRON_SECRET"); + return new Response("Unauthorized", { status: 401 }); + } + + await bot.initialize(); + + const slack = bot.getAdapter("slack"); + if (!slack) { + console.log("[slack-socket-mode] Slack adapter not configured"); + return new Response("Slack adapter not configured", { status: 404 }); + } + + // Construct webhook URL for forwarding socket events + const baseUrl = + process.env.VERCEL_PROJECT_PRODUCTION_URL || + process.env.VERCEL_URL || + process.env.NEXT_PUBLIC_BASE_URL; + let webhookUrl: string | undefined; + if (baseUrl) { + const bypassSecret = process.env.VERCEL_AUTOMATION_BYPASS_SECRET; + const queryParam = bypassSecret + ? `?x-vercel-protection-bypass=${bypassSecret}` + : ""; + webhookUrl = `https://${baseUrl}/api/webhooks/slack${queryParam}`; + } + + return slackSocketMode.start(request, { + afterTask: (task) => after(() => task), + run: async ({ abortSignal, durationMs, listenerId }) => { + console.log( + `[slack-socket-mode] Starting Socket Mode listener: ${listenerId}`, + { + webhookUrl: webhookUrl ? "configured" : "not configured", + durationMs, + } + ); + + const response = await slack.startSocketModeListener( + { waitUntil: (task: Promise) => after(() => task) }, + durationMs, + abortSignal, + webhookUrl + ); + + console.log( + `[slack-socket-mode] Socket Mode listener ${listenerId} completed with status: ${response.status}` + ); + + return response; + }, + }); +} diff --git a/examples/nextjs-chat/vercel.json b/examples/nextjs-chat/vercel.json index 92a6885a..ea74bb9d 100644 --- a/examples/nextjs-chat/vercel.json +++ b/examples/nextjs-chat/vercel.json @@ -4,6 +4,10 @@ { "path": "/api/discord/gateway", "schedule": "*/9 * * * *" + }, + { + "path": "/api/slack/socket-mode", + "schedule": "*/9 * * * *" } ] } diff --git a/packages/adapter-slack/README.md b/packages/adapter-slack/README.md index 85921d09..455dfe2b 100644 --- a/packages/adapter-slack/README.md +++ b/packages/adapter-slack/README.md @@ -102,6 +102,86 @@ openssl rand -base64 32 When `encryptionKey` is set, `setInstallation()` encrypts the token before storing and `getInstallation()` decrypts it transparently. +## Socket mode + +For environments behind firewalls that can't expose public HTTP endpoints, the adapter supports [Slack Socket Mode](https://api.slack.com/apis/socket-mode). Instead of receiving webhooks, the adapter connects to Slack over a WebSocket. + +```typescript +import { Chat } from "chat"; +import { createSlackAdapter } from "@chat-adapter/slack"; + +const bot = new Chat({ + userName: "mybot", + adapters: { + slack: createSlackAdapter({ + mode: "socket", + appToken: process.env.SLACK_APP_TOKEN!, + botToken: process.env.SLACK_BOT_TOKEN!, + }), + }, +}); +``` + +### Slack app setup for socket mode + +1. Go to your app's settings at [api.slack.com/apps](https://api.slack.com/apps) +2. Navigate to **Socket Mode** and enable it +3. Generate an **App-Level Token** with the `connections:write` scope — this is your `SLACK_APP_TOKEN` (`xapp-...`) +4. Event subscriptions and interactivity still need to be configured, but no public request URL is required + +> Socket mode is not compatible with multi-workspace OAuth (`clientId`/`clientSecret`). It's designed for single-workspace deployments. + +### Socket mode on serverless (Vercel) + +Socket mode requires a persistent WebSocket connection, which doesn't fit the request/response model of serverless functions. The adapter provides a forwarding mechanism to bridge this gap: + +1. A cron job periodically starts a transient socket listener +2. The listener connects via WebSocket, acks events immediately, and forwards them as HTTP requests to your webhook endpoint +3. Your existing webhook route processes the forwarded events normally + +```typescript +// api/slack/socket-mode/route.ts +import { after } from "next/server"; +import { bot } from "@/lib/bot"; + +export const maxDuration = 800; + +export async function GET(request: Request) { + const authHeader = request.headers.get("authorization"); + if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) { + return new Response("Unauthorized", { status: 401 }); + } + + await bot.initialize(); + + const slack = bot.getAdapter("slack"); + const webhookUrl = `https://${process.env.VERCEL_URL}/api/webhooks/slack`; + + return slack.startSocketModeListener( + { waitUntil: (task: Promise) => after(() => task) }, + 600_000, // 10 minutes + undefined, + webhookUrl + ); +} +``` + +Schedule the cron job to run every 9 minutes (overlapping with the 10-minute listener duration) to maintain continuous coverage: + +```json +// vercel.json +{ + "crons": [ + { + "path": "/api/slack/socket-mode", + "schedule": "*/9 * * * *" + } + ] +} +``` + +Forwarded events are authenticated using the `socketForwardingSecret` config option (defaults to `SLACK_SOCKET_FORWARDING_SECRET` env var, falling back to `appToken`). + ## Slack app setup ### 1. Create a Slack app from manifest @@ -188,19 +268,25 @@ All options are auto-detected from environment variables when not provided. You |--------|----------|-------------| | `botToken` | No | Bot token (`xoxb-...`). Auto-detected from `SLACK_BOT_TOKEN` | | `signingSecret` | No* | Signing secret for webhook verification. Auto-detected from `SLACK_SIGNING_SECRET` | +| `mode` | No | Connection mode: `"webhook"` (default) or `"socket"` | +| `appToken` | No** | App-level token (`xapp-...`) for socket mode. Auto-detected from `SLACK_APP_TOKEN` | +| `socketForwardingSecret` | No | Shared secret for authenticating forwarded socket events. Auto-detected from `SLACK_SOCKET_FORWARDING_SECRET`, falls back to `appToken` | | `clientId` | No | App client ID for multi-workspace OAuth. Auto-detected from `SLACK_CLIENT_ID` | | `clientSecret` | No | App client secret for multi-workspace OAuth. Auto-detected from `SLACK_CLIENT_SECRET` | | `encryptionKey` | No | AES-256-GCM key for encrypting stored tokens. Auto-detected from `SLACK_ENCRYPTION_KEY` | | `installationKeyPrefix` | No | Prefix for the state key used to store workspace installations. Defaults to `slack:installation`. The full key is `{prefix}:{teamId}` | | `logger` | No | Logger instance (defaults to `ConsoleLogger("info")`) | -*`signingSecret` is required — either via config or `SLACK_SIGNING_SECRET` env var. +*`signingSecret` is required for webhook mode — either via config or `SLACK_SIGNING_SECRET` env var. +**`appToken` is required for socket mode — either via config or `SLACK_APP_TOKEN` env var. ## Environment variables ```bash SLACK_BOT_TOKEN=xoxb-... # Single-workspace only -SLACK_SIGNING_SECRET=... +SLACK_SIGNING_SECRET=... # Required for webhook mode +SLACK_APP_TOKEN=xapp-... # Required for socket mode +SLACK_SOCKET_FORWARDING_SECRET=... # Optional, for socket event forwarding auth SLACK_CLIENT_ID=... # Multi-workspace only SLACK_CLIENT_SECRET=... # Multi-workspace only SLACK_ENCRYPTION_KEY=... # Optional, for token encryption diff --git a/packages/adapter-slack/package.json b/packages/adapter-slack/package.json index 131693f7..876badb3 100644 --- a/packages/adapter-slack/package.json +++ b/packages/adapter-slack/package.json @@ -25,6 +25,7 @@ }, "dependencies": { "@chat-adapter/shared": "workspace:*", + "@slack/socket-mode": "^2.0.5", "@slack/web-api": "^7.14.0", "chat": "workspace:*" }, diff --git a/packages/adapter-slack/src/index.test.ts b/packages/adapter-slack/src/index.test.ts index 41e593f8..8a5c84d9 100644 --- a/packages/adapter-slack/src/index.test.ts +++ b/packages/adapter-slack/src/index.test.ts @@ -16,6 +16,25 @@ import { createSlackAdapter, SlackAdapter } from "./index"; const FILE_ID_PATTERN = /^file-/; +// Mock @slack/socket-mode +const mockSocketStart = vi.fn().mockResolvedValue({}); +const mockSocketDisconnect = vi.fn().mockResolvedValue(undefined); +const mockSocketOn = vi.fn(); + +vi.mock("@slack/socket-mode", () => { + return { + SocketModeClient: class MockSocketModeClient { + start = mockSocketStart; + disconnect = mockSocketDisconnect; + on = mockSocketOn; + constructor(_opts: Record) { + MockSocketModeClient.lastOpts = _opts; + } + static lastOpts: Record = {}; + }, + }; +}); + const mockLogger: Logger = { debug: vi.fn(), info: vi.fn(), @@ -5190,3 +5209,767 @@ describe("reverse user lookup", () => { }); }); }); + +// ============================================================================ +// Socket Mode Tests +// ============================================================================ + +describe("socket mode - factory validation", () => { + it("throws without appToken in socket mode", () => { + expect(() => + createSlackAdapter({ + mode: "socket", + botToken: "xoxb-test-token", + logger: mockLogger, + }) + ).toThrow(ValidationError); + }); + + it("creates adapter with appToken in socket mode", () => { + const adapter = createSlackAdapter({ + mode: "socket", + appToken: "xapp-test-token", + botToken: "xoxb-test-token", + logger: mockLogger, + }); + expect(adapter).toBeInstanceOf(SlackAdapter); + expect(adapter.isSocketMode).toBe(true); + }); + + it("does not require signingSecret in socket mode", () => { + const adapter = createSlackAdapter({ + mode: "socket", + appToken: "xapp-test-token", + botToken: "xoxb-test-token", + logger: mockLogger, + }); + expect(adapter).toBeInstanceOf(SlackAdapter); + }); + + it("rejects multi-workspace config in socket mode", () => { + expect(() => + createSlackAdapter({ + mode: "socket", + appToken: "xapp-test-token", + botToken: "xoxb-test-token", + clientId: "client-id", + clientSecret: "client-secret", + logger: mockLogger, + }) + ).toThrow(ValidationError); + }); + + it("isSocketMode returns false for webhook mode", () => { + const adapter = createSlackAdapter({ + botToken: "xoxb-test-token", + signingSecret: "test-secret", + logger: mockLogger, + }); + expect(adapter.isSocketMode).toBe(false); + }); +}); + +describe("socket mode - handleWebhook", () => { + it("returns 405 in socket mode", async () => { + const adapter = createSlackAdapter({ + mode: "socket", + appToken: "xapp-test-token", + botToken: "xoxb-test-token", + logger: mockLogger, + }); + + const request = new Request("https://example.com/webhook", { + method: "POST", + body: "{}", + }); + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(405); + }); +}); + +describe("socket mode - initialize", () => { + it("creates SocketModeClient and starts on initialize", async () => { + const { SocketModeClient: MockedClient } = await import( + "@slack/socket-mode" + ); + + mockSocketStart.mockClear(); + mockSocketOn.mockClear(); + + const state = createMockState(); + const adapter = createSlackAdapter({ + mode: "socket", + appToken: "xapp-test-token", + botToken: "xoxb-test-token", + logger: mockLogger, + }); + + await adapter.initialize(createMockChatInstance(state)); + + expect( + (MockedClient as unknown as { lastOpts: Record }) + .lastOpts + ).toEqual({ + appToken: "xapp-test-token", + }); + expect(mockSocketOn).toHaveBeenCalledWith( + "slack_event", + expect.any(Function) + ); + expect(mockSocketStart).toHaveBeenCalled(); + }); +}); + +describe("socket mode - routeSocketEvent", () => { + async function createSocketAdapter() { + mockSocketStart.mockClear(); + mockSocketOn.mockClear(); + mockSocketDisconnect.mockClear(); + + const state = createMockState(); + const chatInstance = createMockChatInstance(state); + const adapter = createSlackAdapter({ + mode: "socket", + appToken: "xapp-test-token", + botToken: "xoxb-test-token", + logger: mockLogger, + }); + + await adapter.initialize(chatInstance); + + const slackEventHandler = mockSocketOn.mock.calls.find( + (call: unknown[]) => call[0] === "slack_event" + )?.[1] as (args: { + ack: (response?: Record) => Promise; + body: Record; + type: string; + retry_num?: number; + }) => Promise; + + return { adapter, chatInstance, slackEventHandler }; + } + + it("dispatches events_api to processMessage", async () => { + const { chatInstance, slackEventHandler } = await createSocketAdapter(); + + await slackEventHandler({ + ack: vi.fn().mockResolvedValue(undefined), + type: "events_api", + body: { + event: { + type: "message", + channel: "C123", + ts: "1234567890.123456", + text: "hello from socket", + user: "U_USER", + }, + }, + }); + + expect(chatInstance.processMessage).toHaveBeenCalled(); + }); + + it("dispatches slash_commands to processSlashCommand", async () => { + const { chatInstance, slackEventHandler } = await createSocketAdapter(); + + await slackEventHandler({ + ack: vi.fn().mockResolvedValue(undefined), + type: "slash_commands", + body: { + command: "/test", + text: "arg1", + user_id: "U_USER", + channel_id: "C123", + }, + }); + + await vi.waitFor(() => { + expect(chatInstance.processSlashCommand).toHaveBeenCalled(); + }); + }); + + it("dispatches interactive payloads to processAction", async () => { + const { chatInstance, slackEventHandler } = await createSocketAdapter(); + + await slackEventHandler({ + ack: vi.fn().mockResolvedValue(undefined), + type: "interactive", + body: { + type: "block_actions", + actions: [ + { + type: "button", + action_id: "test_action", + value: "clicked", + }, + ], + channel: { id: "C123", name: "test" }, + container: { + type: "message", + message_ts: "1234567890.123456", + channel_id: "C123", + }, + message: { ts: "1234567890.123456" }, + trigger_id: "trigger123", + user: { id: "U_USER", username: "testuser" }, + }, + }); + + expect(chatInstance.processAction).toHaveBeenCalled(); + }); + + it("acks interactive with response payload for view_submission", async () => { + const { slackEventHandler } = await createSocketAdapter(); + const ack = vi.fn().mockResolvedValue(undefined); + + await slackEventHandler({ + ack, + type: "interactive", + body: { + type: "block_actions", + actions: [{ type: "button", action_id: "test", value: "v" }], + channel: { id: "C123", name: "test" }, + container: { type: "message", message_ts: "1.1", channel_id: "C123" }, + message: { ts: "1.1" }, + trigger_id: "t123", + user: { id: "U1", username: "u" }, + }, + }); + + expect(ack).toHaveBeenCalled(); + }); + + it("skips retries", async () => { + const { chatInstance, slackEventHandler } = await createSocketAdapter(); + + await slackEventHandler({ + ack: vi.fn().mockResolvedValue(undefined), + type: "events_api", + body: { + event: { + type: "message", + channel: "C123", + ts: "1234567890.123456", + text: "retried", + user: "U_USER", + }, + }, + retry_num: 1, + }); + + expect(chatInstance.processMessage).not.toHaveBeenCalled(); + }); + + it("acks events_api before processing", async () => { + const { slackEventHandler } = await createSocketAdapter(); + const ack = vi.fn().mockResolvedValue(undefined); + + await slackEventHandler({ + ack, + type: "events_api", + body: { + event: { + type: "message", + channel: "C123", + ts: "1234567890.123456", + text: "test", + user: "U_USER", + }, + }, + }); + + expect(ack).toHaveBeenCalledWith(); + }); + + it("logs unhandled event types", async () => { + const { slackEventHandler } = await createSocketAdapter(); + + await slackEventHandler({ + ack: vi.fn().mockResolvedValue(undefined), + type: "hello", + body: {}, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Unhandled socket mode event type", + { type: "hello" } + ); + }); +}); + +describe("socket mode - disconnect", () => { + it("calls socketClient.disconnect()", async () => { + mockSocketStart.mockClear(); + mockSocketOn.mockClear(); + mockSocketDisconnect.mockClear(); + + const state = createMockState(); + const adapter = createSlackAdapter({ + mode: "socket", + appToken: "xapp-test-token", + botToken: "xoxb-test-token", + logger: mockLogger, + }); + + await adapter.initialize(createMockChatInstance(state)); + await adapter.disconnect(); + + expect(mockSocketDisconnect).toHaveBeenCalled(); + }); + + it("is a no-op when not connected", async () => { + const adapter = createSlackAdapter({ + botToken: "xoxb-test-token", + signingSecret: "test-secret", + logger: mockLogger, + }); + + // Should not throw + await adapter.disconnect(); + }); +}); + +// ============================================================================ +// Socket Mode Forwarding Tests +// ============================================================================ + +describe("socket mode forwarding - handleWebhook", () => { + const secret = "test-signing-secret"; + const appToken = "xapp-forwarding-token"; + + it("accepts forwarded event with valid appToken", async () => { + const state = createMockState(); + const chatInstance = createMockChatInstance(state); + const adapter = createSlackAdapter({ + botToken: "xoxb-test-token", + signingSecret: secret, + appToken, + logger: mockLogger, + }); + await adapter.initialize(chatInstance); + + const body = JSON.stringify({ + type: "socket_event", + eventType: "events_api", + body: { + event: { + type: "message", + user: "U123", + channel: "C456", + text: "forwarded message", + ts: "1234567890.123456", + }, + }, + timestamp: Date.now(), + }); + + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { + "content-type": "application/json", + "x-slack-socket-token": appToken, + }, + body, + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(200); + expect(chatInstance.processMessage).toHaveBeenCalled(); + }); + + it("rejects forwarded event with invalid token", async () => { + const adapter = createSlackAdapter({ + botToken: "xoxb-test-token", + signingSecret: secret, + appToken, + logger: mockLogger, + }); + + const body = JSON.stringify({ + type: "socket_event", + eventType: "events_api", + body: {}, + timestamp: Date.now(), + }); + + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { + "content-type": "application/json", + "x-slack-socket-token": "wrong-token", + }, + body, + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(401); + }); + + it("rejects forwarded event when no appToken configured", async () => { + const adapter = createSlackAdapter({ + botToken: "xoxb-test-token", + signingSecret: secret, + logger: mockLogger, + }); + + const body = JSON.stringify({ + type: "socket_event", + eventType: "events_api", + body: {}, + timestamp: Date.now(), + }); + + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { + "content-type": "application/json", + "x-slack-socket-token": "any-token", + }, + body, + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(401); + }); + + it("accepts forwarded event with dedicated socketForwardingSecret", async () => { + const forwardingSecret = "my-forwarding-secret"; + const state = createMockState(); + const chatInstance = createMockChatInstance(state); + const adapter = createSlackAdapter({ + botToken: "xoxb-test-token", + signingSecret: secret, + appToken, + socketForwardingSecret: forwardingSecret, + logger: mockLogger, + }); + await adapter.initialize(chatInstance); + + const body = JSON.stringify({ + type: "socket_event", + eventType: "events_api", + body: { + event: { + type: "message", + user: "U123", + channel: "C456", + text: "forwarded with dedicated secret", + ts: "1234567890.123456", + }, + }, + timestamp: Date.now(), + }); + + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { + "content-type": "application/json", + "x-slack-socket-token": forwardingSecret, + }, + body, + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(200); + }); + + it("rejects forwarded event with appToken when socketForwardingSecret is set", async () => { + const adapter = createSlackAdapter({ + botToken: "xoxb-test-token", + signingSecret: secret, + appToken, + socketForwardingSecret: "my-forwarding-secret", + logger: mockLogger, + }); + + const body = JSON.stringify({ + type: "socket_event", + eventType: "events_api", + body: {}, + timestamp: Date.now(), + }); + + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { + "content-type": "application/json", + "x-slack-socket-token": appToken, + }, + body, + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(401); + }); + + it("bypasses signature verification for forwarded events", async () => { + const state = createMockState(); + const chatInstance = createMockChatInstance(state); + const adapter = createSlackAdapter({ + botToken: "xoxb-test-token", + signingSecret: secret, + appToken, + logger: mockLogger, + }); + await adapter.initialize(chatInstance); + + const body = JSON.stringify({ + type: "socket_event", + eventType: "events_api", + body: { + event: { + type: "message", + user: "U123", + channel: "C456", + text: "no sig needed", + ts: "1234567890.123456", + }, + }, + timestamp: Date.now(), + }); + + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { + "content-type": "application/json", + "x-slack-socket-token": appToken, + }, + body, + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(200); + }); + + it("passes options through to handlers for forwarded events", async () => { + const state = createMockState(); + const chatInstance = createMockChatInstance(state); + const adapter = createSlackAdapter({ + botToken: "xoxb-test-token", + signingSecret: secret, + appToken, + logger: mockLogger, + }); + await adapter.initialize(chatInstance); + + const waitUntil = vi.fn(); + const body = JSON.stringify({ + type: "socket_event", + eventType: "events_api", + body: { + event: { + type: "message", + user: "U123", + channel: "C456", + text: "with options", + ts: "1234567890.123456", + }, + }, + timestamp: Date.now(), + }); + + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { + "content-type": "application/json", + "x-slack-socket-token": appToken, + }, + body, + }); + + const response = await adapter.handleWebhook(request, { waitUntil }); + expect(response.status).toBe(200); + // processMessage receives the options + expect(chatInstance.processMessage).toHaveBeenCalledWith( + adapter, + expect.any(String), + expect.any(Function), + { waitUntil } + ); + }); +}); + +describe("startSocketModeListener", () => { + const secret = "test-signing-secret"; + + it("returns 200 with valid config", async () => { + const adapter = createSlackAdapter({ + botToken: "xoxb-test-token", + signingSecret: secret, + appToken: "xapp-test-token", + logger: mockLogger, + }); + + const waitUntil = vi.fn(); + const response = await adapter.startSocketModeListener({ waitUntil }, 1000); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.status).toBe("listening"); + expect(waitUntil).toHaveBeenCalled(); + }); + + it("returns 500 without appToken", async () => { + const adapter = createSlackAdapter({ + botToken: "xoxb-test-token", + signingSecret: secret, + logger: mockLogger, + }); + + const response = await adapter.startSocketModeListener( + { waitUntil: vi.fn() }, + 1000 + ); + + expect(response.status).toBe(500); + expect(await response.text()).toContain("appToken"); + }); + + it("returns 500 without waitUntil", async () => { + const adapter = createSlackAdapter({ + botToken: "xoxb-test-token", + signingSecret: secret, + appToken: "xapp-test-token", + logger: mockLogger, + }); + + const response = await adapter.startSocketModeListener({}, 1000); + + expect(response.status).toBe(500); + expect(await response.text()).toContain("waitUntil"); + }); +}); + +describe("routeSocketEvent with options", () => { + async function createSocketAdapterWithOptions() { + mockSocketStart.mockClear(); + mockSocketOn.mockClear(); + mockSocketDisconnect.mockClear(); + + const state = createMockState(); + const chatInstance = createMockChatInstance(state); + const adapter = createSlackAdapter({ + mode: "socket", + appToken: "xapp-test-token", + botToken: "xoxb-test-token", + logger: mockLogger, + }); + + await adapter.initialize(chatInstance); + + const slackEventHandler = mockSocketOn.mock.calls.find( + (call: unknown[]) => call[0] === "slack_event" + )?.[1] as (args: { + ack: (response?: Record) => Promise; + body: Record; + type: string; + retry_num?: number; + }) => Promise; + + return { adapter, chatInstance, slackEventHandler }; + } + + it("dispatches slash_commands with waitUntil wrapping", async () => { + const { chatInstance, slackEventHandler } = + await createSocketAdapterWithOptions(); + + await slackEventHandler({ + ack: vi.fn().mockResolvedValue(undefined), + type: "slash_commands", + body: { + command: "/test", + text: "arg1", + user_id: "U_USER", + channel_id: "C123", + }, + }); + + await vi.waitFor(() => { + expect(chatInstance.processSlashCommand).toHaveBeenCalled(); + }); + }); + + it("forwards slash_commands with eventType in forwarded envelope", async () => { + const state = createMockState(); + const chatInstance = createMockChatInstance(state); + const adapter = createSlackAdapter({ + botToken: "xoxb-test-token", + signingSecret: "test-secret", + appToken: "xapp-test-token", + logger: mockLogger, + }); + await adapter.initialize(chatInstance); + + const body = JSON.stringify({ + type: "socket_event", + eventType: "slash_commands", + body: { + command: "/deploy", + text: "production", + user_id: "U_USER", + channel_id: "C123", + }, + timestamp: Date.now(), + }); + + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { + "content-type": "application/json", + "x-slack-socket-token": "xapp-test-token", + }, + body, + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(200); + + await vi.waitFor(() => { + expect(chatInstance.processSlashCommand).toHaveBeenCalled(); + }); + }); + + it("forwards interactive payloads with eventType in forwarded envelope", async () => { + const state = createMockState(); + const chatInstance = createMockChatInstance(state); + const adapter = createSlackAdapter({ + botToken: "xoxb-test-token", + signingSecret: "test-secret", + appToken: "xapp-test-token", + logger: mockLogger, + }); + await adapter.initialize(chatInstance); + + const body = JSON.stringify({ + type: "socket_event", + eventType: "interactive", + body: { + type: "block_actions", + actions: [{ type: "button", action_id: "approve", value: "yes" }], + channel: { id: "C123", name: "test" }, + container: { + type: "message", + message_ts: "1234567890.123456", + channel_id: "C123", + }, + message: { ts: "1234567890.123456" }, + trigger_id: "trigger123", + user: { id: "U_USER", username: "testuser" }, + }, + timestamp: Date.now(), + }); + + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { + "content-type": "application/json", + "x-slack-socket-token": "xapp-test-token", + }, + body, + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(200); + expect(chatInstance.processAction).toHaveBeenCalled(); + }); +}); diff --git a/packages/adapter-slack/src/index.ts b/packages/adapter-slack/src/index.ts index 784a242a..535d1c2d 100644 --- a/packages/adapter-slack/src/index.ts +++ b/packages/adapter-slack/src/index.ts @@ -9,6 +9,7 @@ import { toBuffer, ValidationError, } from "@chat-adapter/shared"; +import { SocketModeClient } from "@slack/socket-mode"; import { WebClient } from "@slack/web-api"; import type { ActionEvent, @@ -95,9 +96,21 @@ function findNextMention(text: string): number { const SLACK_MESSAGE_URL_PATTERN = /^https?:\/\/[^/]+\.slack\.com\/archives\/([A-Z0-9]+)\/p(\d+)(?:\?.*)?$/; +export type SlackAdapterMode = "webhook" | "socket"; + +/** Envelope for events forwarded from a socket mode listener via HTTP POST */ +interface SlackForwardedSocketEvent { + body: Record; + eventType: string; + timestamp: number; + type: "socket_event"; +} + export interface SlackAdapterConfig { /** Override the Slack API base URL (e.g. "https://slack-gov.com/api/" for GovSlack). Defaults to SLACK_API_URL env var. */ apiUrl?: string; + /** App-level token (xapp-...). Required for socket mode. */ + appToken?: string; /** Bot token (xoxb-...). Required for single-workspace mode. Omit for multi-workspace. */ botToken?: string; /** Bot user ID (will be fetched if not provided) */ @@ -118,8 +131,12 @@ export interface SlackAdapterConfig { installationKeyPrefix?: string; /** Logger instance for error reporting. Defaults to ConsoleLogger. */ logger?: Logger; + /** Connection mode: "webhook" (default) or "socket" */ + mode?: SlackAdapterMode; /** Signing secret for webhook verification. Defaults to SLACK_SIGNING_SECRET env var. */ signingSecret?: string; + /** Shared secret for authenticating forwarded socket mode events. Auto-detected from SLACK_SOCKET_FORWARDING_SECRET. Falls back to appToken if not set. */ + socketForwardingSecret?: string; /** Override bot username (optional) */ userName?: string; } @@ -377,7 +394,7 @@ export class SlackAdapter implements Adapter { readonly userName: string; private readonly client: WebClient; - private readonly signingSecret: string; + private readonly signingSecret: string | undefined; private readonly defaultBotToken: string | undefined; private chat: ChatInstance | null = null; private readonly logger: Logger; @@ -394,6 +411,12 @@ export class SlackAdapter implements Adapter { */ private readonly _externalChannels = new Set(); + // Socket mode support + private readonly appToken: string | undefined; + private readonly mode: SlackAdapterMode; + private readonly socketForwardingSecret: string | undefined; + private socketClient: SocketModeClient | null = null; + // Multi-workspace support private readonly clientId: string | undefined; private readonly clientSecret: string | undefined; @@ -414,13 +437,17 @@ export class SlackAdapter implements Adapter { return this._botUserId || undefined; } + get isSocketMode(): boolean { + return this.mode === "socket"; + } + constructor(config: SlackAdapterConfig = {}) { const signingSecret = config.signingSecret ?? process.env.SLACK_SIGNING_SECRET; - if (!signingSecret) { + if (!signingSecret && (config.mode ?? "webhook") === "webhook") { throw new ValidationError( "slack", - "signingSecret is required. Set SLACK_SIGNING_SECRET or provide it in config." + "signingSecret is required for webhook mode. Set SLACK_SIGNING_SECRET or provide it in config." ); } @@ -448,6 +475,11 @@ export class SlackAdapter implements Adapter { this.userName = config.userName || "bot"; this._botUserId = config.botUserId || null; + this.appToken = config.appToken; + this.mode = config.mode ?? "webhook"; + this.socketForwardingSecret = + config.socketForwardingSecret ?? config.appToken; + this.clientId = config.clientId ?? (zeroConfig ? process.env.SLACK_CLIENT_ID : undefined); this.clientSecret = @@ -516,6 +548,10 @@ export class SlackAdapter implements Adapter { if (!this.defaultBotToken) { this.logger.info("Slack adapter initialized in multi-workspace mode"); } + + if (this.mode === "socket") { + await this.startSocketMode(); + } } // =========================================================================== @@ -837,6 +873,39 @@ export class SlackAdapter implements Adapter { request: Request, options?: WebhookOptions ): Promise { + // Check for forwarded socket mode events (from external socket listener) + const socketToken = request.headers.get("x-slack-socket-token"); + if (socketToken) { + if ( + !this.socketForwardingSecret || + socketToken !== this.socketForwardingSecret + ) { + this.logger.warn("Invalid socket forwarding token"); + return new Response("Invalid socket token", { status: 401 }); + } + this.logger.info("Slack forwarded socket event received"); + try { + const body = await request.text(); + const event = JSON.parse(body) as SlackForwardedSocketEvent; + const noopAck = async () => {}; + await this.routeSocketEvent( + event.body, + event.eventType, + noopAck, + options + ); + return new Response("ok", { status: 200 }); + } catch { + return new Response("Invalid JSON", { status: 400 }); + } + } + + if (this.mode === "socket") { + return new Response("Webhooks are disabled in socket mode", { + status: 405, + }); + } + const body = await request.text(); this.logger.debug("Slack webhook raw body", { body }); @@ -995,6 +1064,17 @@ export class SlackAdapter implements Adapter { return new Response("Invalid payload JSON", { status: 400 }); } + return this.dispatchInteractivePayload(payload, options); + } + + /** + * Dispatch a pre-parsed interactive payload to the correct handler. + * Used by both webhook and socket mode paths. + */ + private dispatchInteractivePayload( + payload: SlackInteractivePayload, + options?: WebhookOptions + ): Response | Promise { switch (payload.type) { case "block_actions": this.handleBlockActions(payload, options); @@ -1273,12 +1353,321 @@ export class SlackAdapter implements Adapter { return modal; } + // =========================================================================== + // Socket Mode + // =========================================================================== + + /** + * Start Socket Mode connection. + * Creates a SocketModeClient, registers event handlers, and connects. + */ + private async startSocketMode(): Promise { + if (!this.appToken) { + throw new ValidationError( + "slack", + "appToken is required for socket mode. Set SLACK_APP_TOKEN or provide it in config." + ); + } + + this.socketClient = new SocketModeClient({ appToken: this.appToken }); + + this.socketClient.on( + "slack_event", + async ({ ack, body, type, retry_num }) => { + if (retry_num && retry_num > 0) { + await ack(); + this.logger.debug("Skipping socket mode retry", { retry_num }); + return; + } + + await this.routeSocketEvent( + body as Record, + type as string, + ack + ); + } + ); + + await this.socketClient.start(); + this.logger.info("Slack socket mode connected"); + } + + /** + * Route a socket mode event to the appropriate handler. + */ + private async routeSocketEvent( + body: Record, + eventType: string, + ack: (response?: Record) => Promise, + options?: WebhookOptions + ): Promise { + const wrapAsync = (promise: Promise): void => { + if (options?.waitUntil) { + options.waitUntil(promise); + } else { + promise.catch((error) => { + this.logger.error("Error in socket mode async handler", { error }); + }); + } + }; + + switch (eventType) { + case "events_api": { + await ack(); + if (!body.event || typeof body.event !== "object") { + this.logger.warn("Socket mode events_api missing event field", { + body, + }); + break; + } + const payload: SlackWebhookPayload = { + type: "event_callback", + event: body.event as SlackWebhookPayload["event"], + team_id: body.team_id as string | undefined, + event_id: body.event_id as string | undefined, + event_time: body.event_time as number | undefined, + }; + try { + this.processEventPayload(payload, options); + } catch (error) { + this.logger.error("Error processing socket mode events_api", { + error, + }); + } + break; + } + + case "slash_commands": { + await ack(); + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(body)) { + if (typeof value === "string") { + params.set(key, value); + } + } + wrapAsync(this.handleSlashCommand(params, options)); + break; + } + + case "interactive": { + const payload = body as unknown as SlackInteractivePayload; + const result = this.dispatchInteractivePayload(payload, options); + const response = result instanceof Promise ? await result : result; + const responseBody = response.headers + .get("content-type") + ?.includes("application/json") + ? ((await response.json()) as Record) + : undefined; + await ack(responseBody); + break; + } + + default: + await ack(); + this.logger.debug("Unhandled socket mode event type", { + type: eventType, + }); + } + } + + /** + * Start a transient Socket Mode listener for serverless environments. + * The listener maintains a WebSocket for `durationMs`, acks events, and + * forwards them via HTTP POST to the webhook endpoint (or processes directly). + * + * @param options - Webhook options with waitUntil function + * @param durationMs - How long to keep listening (default: 180000ms = 3 minutes) + * @param abortSignal - Optional signal to stop the listener early + * @param webhookUrl - URL to forward socket events to (required for forwarding mode) + */ + async startSocketModeListener( + options: WebhookOptions, + durationMs = 180000, + abortSignal?: AbortSignal, + webhookUrl?: string + ): Promise { + if (!this.appToken) { + return new Response("appToken is required for socket mode listener", { + status: 500, + }); + } + + if (!options.waitUntil) { + return new Response("waitUntil not provided", { status: 500 }); + } + + this.logger.info("Starting Slack socket mode listener", { + durationMs, + webhookUrl: webhookUrl ? "configured" : "not configured", + }); + + const listenerPromise = this.runSocketModeListener( + durationMs, + abortSignal, + webhookUrl, + options + ); + + options.waitUntil(listenerPromise); + + return new Response( + JSON.stringify({ + status: "listening", + durationMs, + message: `Socket mode listener started, will run for ${durationMs / 1000} seconds`, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + } + + /** + * Run the socket mode listener for a specified duration. + */ + private async runSocketModeListener( + durationMs: number, + abortSignal?: AbortSignal, + webhookUrl?: string, + options?: WebhookOptions + ): Promise { + // appToken is guaranteed to exist — callers check before invoking + const appToken = this.appToken as string; + const client = new SocketModeClient({ appToken }); + let isShuttingDown = false; + + client.on("slack_event", async ({ ack, body, type, retry_num }) => { + if (isShuttingDown) { + return; + } + + if (retry_num && retry_num > 0) { + await ack(); + this.logger.debug("Skipping socket mode retry", { retry_num }); + return; + } + + const eventType = type as string; + if (webhookUrl) { + await ack(); + await this.forwardSocketEvent(webhookUrl, { + type: "socket_event", + eventType, + body: body as Record, + timestamp: Date.now(), + }); + } else { + await this.routeSocketEvent( + body as Record, + eventType, + ack, + options + ); + } + }); + + try { + await client.start(); + this.logger.info("Slack socket mode listener connected"); + + await new Promise((resolve) => { + const timeout = setTimeout(resolve, durationMs); + + if (abortSignal) { + if (abortSignal.aborted) { + clearTimeout(timeout); + resolve(); + return; + } + abortSignal.addEventListener( + "abort", + () => { + this.logger.info( + "Slack socket mode listener received abort signal" + ); + clearTimeout(timeout); + resolve(); + }, + { once: true } + ); + } + }); + + this.logger.info( + "Slack socket mode listener duration elapsed, disconnecting" + ); + } catch (error) { + this.logger.error("Slack socket mode listener error", { + error: String(error), + }); + } finally { + isShuttingDown = true; + await client.disconnect(); + this.logger.info("Slack socket mode listener stopped"); + } + } + + /** + * Forward a socket mode event to the webhook endpoint. + */ + private async forwardSocketEvent( + webhookUrl: string, + event: SlackForwardedSocketEvent + ): Promise { + try { + this.logger.debug("Forwarding socket event to webhook", { + type: (event.body.type as string) || "unknown", + webhookUrl, + }); + + const response = await fetch(webhookUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-slack-socket-token": this.socketForwardingSecret as string, + }, + body: JSON.stringify(event), + }); + + if (response.ok) { + this.logger.debug("Socket event forwarded successfully", { + type: (event.body.type as string) || "unknown", + }); + } else { + const errorText = await response.text(); + this.logger.error("Failed to forward socket event", { + type: (event.body.type as string) || "unknown", + status: response.status, + error: errorText, + }); + } + } catch (error) { + this.logger.error("Error forwarding socket event", { + type: (event.body.type as string) || "unknown", + error: String(error), + }); + } + } + + /** + * Disconnect the socket mode client. + * No-op if not connected. + */ + async disconnect(): Promise { + if (this.socketClient) { + await this.socketClient.disconnect(); + this.socketClient = null; + this.logger.info("Slack socket mode disconnected"); + } + } + private verifySignature( body: string, timestamp: string | null, signature: string | null ): boolean { - if (!(timestamp && signature)) { + if (!(timestamp && signature && this.signingSecret)) { return false; } @@ -4180,8 +4569,65 @@ export class SlackAdapter implements Adapter { } } -export function createSlackAdapter(config?: SlackAdapterConfig): SlackAdapter { - return new SlackAdapter(config ?? {}); +export function createSlackAdapter( + config?: Partial +): SlackAdapter { + const mode = config?.mode ?? "webhook"; + const appToken = config?.appToken ?? process.env.SLACK_APP_TOKEN; + + if (mode === "socket") { + if (!appToken) { + throw new ValidationError( + "slack", + "appToken is required for socket mode. Set SLACK_APP_TOKEN or provide it in config." + ); + } + if (config?.clientId || config?.clientSecret) { + throw new ValidationError( + "slack", + "Multi-workspace (clientId/clientSecret) is not supported in socket mode." + ); + } + } + + const signingSecret = + config?.signingSecret ?? process.env.SLACK_SIGNING_SECRET; + if (mode === "webhook" && !signingSecret) { + throw new ValidationError( + "slack", + "signingSecret is required. Set SLACK_SIGNING_SECRET or provide it in config." + ); + } + + // Auth fields (botToken, clientId, clientSecret) are modal: botToken's + // presence selects single-workspace mode, its absence selects multi-workspace + // (per-team token lookup via installations). Only fall back to env vars + // in zero-config mode (no config provided at all). + const zeroConfig = !config; + + const resolved: SlackAdapterConfig = { + appToken, + mode, + signingSecret, + botToken: + config?.botToken ?? + (zeroConfig ? process.env.SLACK_BOT_TOKEN : undefined), + clientId: + config?.clientId ?? + (zeroConfig ? process.env.SLACK_CLIENT_ID : undefined), + clientSecret: + config?.clientSecret ?? + (zeroConfig ? process.env.SLACK_CLIENT_SECRET : undefined), + encryptionKey: config?.encryptionKey ?? process.env.SLACK_ENCRYPTION_KEY, + installationKeyPrefix: config?.installationKeyPrefix, + logger: config?.logger ?? new ConsoleLogger("info").child("slack"), + socketForwardingSecret: + config?.socketForwardingSecret ?? + process.env.SLACK_SOCKET_FORWARDING_SECRET, + userName: config?.userName, + botUserId: config?.botUserId, + }; + return new SlackAdapter(resolved); } // Re-export card converter for advanced use diff --git a/packages/adapter-slack/tsup.config.ts b/packages/adapter-slack/tsup.config.ts index 9d844285..6a9a22f4 100644 --- a/packages/adapter-slack/tsup.config.ts +++ b/packages/adapter-slack/tsup.config.ts @@ -6,5 +6,5 @@ export default defineConfig({ dts: true, clean: true, sourcemap: false, - external: ["@slack/web-api"], + external: ["@slack/web-api", "@slack/socket-mode"], }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dcf29975..f6ce8459 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -384,6 +384,9 @@ importers: '@chat-adapter/shared': specifier: workspace:* version: link:../adapter-shared + '@slack/socket-mode': + specifier: ^2.0.5 + version: 2.0.6 '@slack/web-api': specifier: ^7.14.0 version: 7.14.1 @@ -2541,14 +2544,30 @@ packages: resolution: {integrity: sha512-Wz7QYfPAlG/DR+DfABddUZeNgoeY7d1J39OCR2jR+v7VBsB8ezulDK5szTnDDPDwLH5IWhLvXIHlCFZV7MSKgA==} engines: {node: '>= 18', npm: '>= 8.6.0'} + '@slack/logger@4.0.1': + resolution: {integrity: sha512-6cmdPrV/RYfd2U0mDGiMK8S7OJqpCTm7enMLRR3edccsPX8j7zXTLnaEF4fhxxJJTAIOil6+qZrnUPTuaLvwrQ==} + engines: {node: '>= 18', npm: '>= 8.6.0'} + + '@slack/socket-mode@2.0.6': + resolution: {integrity: sha512-Aj5RO3MoYVJ+b2tUjHUXuA3tiIaCUMOf1Ss5tPiz29XYVUi6qNac2A8ulcU1pUPERpXVHTmT1XW6HzQIO74daQ==} + engines: {node: '>= 18', npm: '>= 8.6.0'} + '@slack/types@2.20.0': resolution: {integrity: sha512-PVF6P6nxzDMrzPC8fSCsnwaI+kF8YfEpxf3MqXmdyjyWTYsZQURpkK7WWUWvP5QpH55pB7zyYL9Qem/xSgc5VA==} engines: {node: '>= 12.13.0', npm: '>= 6.12.0'} + '@slack/types@2.20.1': + resolution: {integrity: sha512-eWX2mdt1ktpn8+40iiMc404uGrih+2fxiky3zBcPjtXKj6HLRdYlmhrPkJi7JTJm8dpXR6BWVWEDBXtaWMKD6A==} + engines: {node: '>= 12.13.0', npm: '>= 6.12.0'} + '@slack/web-api@7.14.1': resolution: {integrity: sha512-RoygyteJeFswxDPJjUMESn9dldWVMD2xUcHHd9DenVavSfVC6FeVnSdDerOO7m8LLvw4Q132nQM4hX8JiF7dng==} engines: {node: '>= 18', npm: '>= 8.6.0'} + '@slack/web-api@7.15.1': + resolution: {integrity: sha512-y+TAF7TszcmFzbVtBkFqAdBwKSoD+8shkNxhp4WIfFwXmCKdFje9WD6evROApPa2FTy1v1uc9yBaJs3609PPgg==} + engines: {node: '>= 18', npm: '>= 8.6.0'} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -3100,6 +3119,9 @@ packages: axios@1.13.6: resolution: {integrity: sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==} + axios@1.15.0: + resolution: {integrity: sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==} + bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} @@ -5094,6 +5116,10 @@ packages: proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + qs@6.15.0: resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} engines: {node: '>=0.6'} @@ -7868,8 +7894,27 @@ snapshots: dependencies: '@types/node': 25.3.2 + '@slack/logger@4.0.1': + dependencies: + '@types/node': 25.3.2 + + '@slack/socket-mode@2.0.6': + dependencies: + '@slack/logger': 4.0.1 + '@slack/web-api': 7.15.1 + '@types/node': 25.3.2 + '@types/ws': 8.18.1 + eventemitter3: 5.0.1 + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - debug + - utf-8-validate + '@slack/types@2.20.0': {} + '@slack/types@2.20.1': {} + '@slack/web-api@7.14.1': dependencies: '@slack/logger': 4.0.0 @@ -7887,6 +7932,23 @@ snapshots: transitivePeerDependencies: - debug + '@slack/web-api@7.15.1': + dependencies: + '@slack/logger': 4.0.1 + '@slack/types': 2.20.1 + '@types/node': 25.3.2 + '@types/retry': 0.12.0 + axios: 1.15.0 + eventemitter3: 5.0.1 + form-data: 4.0.5 + is-electron: 2.2.2 + is-stream: 2.0.1 + p-queue: 6.6.2 + p-retry: 4.6.2 + retry: 0.13.1 + transitivePeerDependencies: + - debug + '@standard-schema/spec@1.1.0': {} '@streamdown/cjk@1.0.2(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2)(react@19.2.3)(unified@11.0.5)': @@ -8390,6 +8452,14 @@ snapshots: transitivePeerDependencies: - debug + axios@1.15.0: + dependencies: + follow-redirects: 1.15.11 + form-data: 4.0.5 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + bail@2.0.2: {} balanced-match@1.0.2: {} @@ -10837,6 +10907,8 @@ snapshots: proxy-from-env@1.1.0: {} + proxy-from-env@2.1.0: {} + qs@6.15.0: dependencies: side-channel: 1.1.0