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/apps/docs/content/docs/adapters/slack.mdx b/apps/docs/content/docs/adapters/slack.mdx index a6d15f26..8c7676a3 100644 --- a/apps/docs/content/docs/adapters/slack.mdx +++ b/apps/docs/content/docs/adapters/slack.mdx @@ -32,6 +32,44 @@ bot.onNewMention(async (thread, message) => { }); ``` +## Socket mode + +For environments behind firewalls that can't expose public HTTP endpoints, Socket Mode connects to Slack via WebSocket. This is useful for corporate networks, local development, or self-hosted deployments. + +```typescript title="lib/bot.ts" lineNumbers +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, + }), + }, +}); +``` + +### Enable Socket Mode in your Slack app + +1. Go to your app settings at [api.slack.com/apps](https://api.slack.com/apps) +2. Navigate to **Socket Mode** and toggle it on +3. Generate an **App-Level Token** with the `connections:write` scope +4. Copy the token (`xapp-...`) as `SLACK_APP_TOKEN` + +Socket Mode is not compatible with multi-workspace OAuth (`clientId`/`clientSecret`), since it uses a single app-level token. + +### Disconnecting + +Call `disconnect()` to close the WebSocket connection: + +```typescript title="lib/bot.ts" +const slackAdapter = bot.getAdapter("slack") as SlackAdapter; +await slackAdapter.disconnect(); +``` + ## Multi-workspace mode For apps installed across multiple Slack workspaces via OAuth, omit `botToken` and provide OAuth credentials instead. The adapter resolves tokens dynamically from your state adapter using the `team_id` from incoming webhooks. @@ -182,21 +220,24 @@ All options are auto-detected from environment variables when not provided. You | Option | Required | Description | |--------|----------|-------------| +| `mode` | No | Connection mode: `"webhook"` (default) or `"socket"` | +| `appToken` | Socket | App-level token (`xapp-...`). Auto-detected from `SLACK_APP_TOKEN`. Required for socket mode | | `botToken` | No | Bot token (`xoxb-...`). Auto-detected from `SLACK_BOT_TOKEN` | -| `signingSecret` | No* | Signing secret for webhook verification. Auto-detected from `SLACK_SIGNING_SECRET` | +| `signingSecret` | Webhook | Signing secret for webhook verification. Auto-detected from `SLACK_SIGNING_SECRET`. Required for webhook mode | | `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}` | +| `socketForwardingSecret` | No | Shared secret for authenticating forwarded socket mode events. Auto-detected from `SLACK_SOCKET_FORWARDING_SECRET`. Falls back to `appToken` | | `logger` | No | Logger instance (defaults to `ConsoleLogger("info")`) | -*`signingSecret` is required — either via config or `SLACK_SIGNING_SECRET` env var. - ## Environment variables ```bash title=".env.local" SLACK_BOT_TOKEN=xoxb-... # Single-workspace only -SLACK_SIGNING_SECRET=... +SLACK_SIGNING_SECRET=... # Webhook mode only +SLACK_APP_TOKEN=xapp-... # Socket mode only +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 @@ -219,6 +260,7 @@ SLACK_ENCRYPTION_KEY=... # Optional, for token encryption | Message history | Yes | | Assistants API | Yes | | App Home tab | Yes | +| Socket Mode | Yes | ## Slack Assistants API 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/package.json b/packages/adapter-slack/package.json index 8f90915b..1108b65c 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.11.0", "chat": "workspace:*" }, diff --git a/packages/adapter-slack/src/index.test.ts b/packages/adapter-slack/src/index.test.ts index 80ead5d1..9d40b52c 100644 --- a/packages/adapter-slack/src/index.test.ts +++ b/packages/adapter-slack/src/index.test.ts @@ -9,6 +9,25 @@ import { describe, expect, it, vi } from "vitest"; import type { SlackInstallation } from "./index"; import { createSlackAdapter, SlackAdapter } from "./index"; +// 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(), @@ -3657,3 +3676,647 @@ describe("handleWebhook - assistant events", () => { ); }); }); + +// ============================================================================ +// 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); + + // Extract the slack_event handler registered via on() + const slackEventHandler = mockSocketOn.mock.calls.find( + (call: unknown[]) => call[0] === "slack_event" + )?.[1] as (args: { + ack: () => Promise; + body: Record; + retry_num?: number; + }) => Promise; + + return { adapter, chatInstance, slackEventHandler }; + } + + it("dispatches event_callback to processMessage", async () => { + const { chatInstance, slackEventHandler } = await createSocketAdapter(); + + await slackEventHandler({ + ack: vi.fn().mockResolvedValue(undefined), + body: { + type: "event_callback", + 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), + body: { + type: "slash_commands", + command: "/test", + text: "arg1", + user_id: "U_USER", + channel_id: "C123", + }, + }); + + // handleSlashCommand is async (user lookup), wait for it to complete + 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), + body: { + type: "interactive", + payload: { + 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("skips retries", async () => { + const { chatInstance, slackEventHandler } = await createSocketAdapter(); + + await slackEventHandler({ + ack: vi.fn().mockResolvedValue(undefined), + body: { + type: "event_callback", + event: { + type: "message", + channel: "C123", + ts: "1234567890.123456", + text: "retried", + user: "U_USER", + }, + }, + retry_num: 1, + }); + + expect(chatInstance.processMessage).not.toHaveBeenCalled(); + }); + + it("acks immediately", async () => { + const { slackEventHandler } = await createSocketAdapter(); + const ack = vi.fn().mockResolvedValue(undefined); + + await slackEventHandler({ + ack, + body: { + type: "event_callback", + event: { + type: "message", + channel: "C123", + ts: "1234567890.123456", + text: "test", + user: "U_USER", + }, + }, + }); + + expect(ack).toHaveBeenCalled(); + }); +}); + +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", + body: { + type: "event_callback", + 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", + body: { type: "event_callback" }, + 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", + body: { type: "event_callback" }, + 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", + body: { + type: "event_callback", + 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", + body: { type: "event_callback" }, + 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); + + // No x-slack-request-timestamp or x-slack-signature headers + const body = JSON.stringify({ + type: "socket_event", + body: { + type: "event_callback", + 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", + body: { + type: "event_callback", + 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: () => Promise; + body: Record; + 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), + body: { + type: "slash_commands", + command: "/test", + text: "arg1", + user_id: "U_USER", + channel_id: "C123", + }, + }); + + await vi.waitFor(() => { + expect(chatInstance.processSlashCommand).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/adapter-slack/src/index.ts b/packages/adapter-slack/src/index.ts index dacacb29..3494e0eb 100644 --- a/packages/adapter-slack/src/index.ts +++ b/packages/adapter-slack/src/index.ts @@ -8,6 +8,7 @@ import { toBuffer, ValidationError, } from "@chat-adapter/shared"; +import { SocketModeClient } from "@slack/socket-mode"; import { WebClient } from "@slack/web-api"; import type { ActionEvent, @@ -60,7 +61,18 @@ import { type SlackModalResponse, } from "./modals"; +export type SlackAdapterMode = "webhook" | "socket"; + +/** Envelope for events forwarded from a socket mode listener via HTTP POST */ +interface SlackForwardedSocketEvent { + body: Record; + timestamp: number; + type: "socket_event"; +} + export interface SlackAdapterConfig { + /** 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) */ @@ -81,8 +93,12 @@ export interface SlackAdapterConfig { installationKeyPrefix?: string; /** Logger instance for error reporting */ logger: Logger; - /** Signing secret for webhook verification */ - signingSecret: string; + /** Connection mode: "webhook" (default) or "socket" */ + mode?: SlackAdapterMode; + /** Signing secret for webhook verification. Required for webhook mode. */ + 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; } @@ -291,7 +307,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; @@ -300,6 +316,12 @@ export class SlackAdapter implements Adapter { private readonly formatConverter = new SlackFormatConverter(); private static USER_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour + // 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; @@ -319,6 +341,10 @@ export class SlackAdapter implements Adapter { return this._botUserId || undefined; } + get isSocketMode(): boolean { + return this.mode === "socket"; + } + constructor(config: SlackAdapterConfig) { this.client = new WebClient(config.botToken); this.signingSecret = config.signingSecret; @@ -327,6 +353,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; this.clientSecret = config.clientSecret; this.installationKeyPrefix = @@ -390,6 +421,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(); + } } // =========================================================================== @@ -656,6 +691,33 @@ 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; + this.routeSocketEvent(event.body, 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 }); @@ -794,6 +856,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); @@ -1069,12 +1142,303 @@ 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, retry_num }) => { + // Immediately ack to prevent retries + await ack(); + + // Skip retries + if (retry_num && retry_num > 0) { + this.logger.debug("Skipping socket mode retry", { retry_num }); + return; + } + + this.routeSocketEvent(body); + }); + + await this.socketClient.start(); + this.logger.info("Slack socket mode connected"); + } + + /** + * Route a socket mode event to the appropriate handler. + */ + private routeSocketEvent( + body: Record, + options?: WebhookOptions + ): void { + const type = body.type as string; + + 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 (type) { + case "event_callback": { + if (!body.event || typeof body.event !== "object") { + this.logger.warn("Socket mode event_callback missing event field", { + body, + }); + break; + } + const payload: SlackWebhookPayload = { + type: body.type as string, + 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 event_callback", { + error, + }); + } + break; + } + + case "slash_commands": { + 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.payload as SlackInteractivePayload | undefined; + if (payload) { + const result = this.dispatchInteractivePayload(payload, options); + if (result instanceof Promise) { + wrapAsync(result); + } + } + break; + } + + default: + this.logger.debug("Unhandled socket mode event type", { type }); + } + } + + /** + * 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, retry_num }) => { + if (isShuttingDown) { + return; + } + + await ack(); + + if (retry_num && retry_num > 0) { + this.logger.debug("Skipping socket mode retry", { retry_num }); + return; + } + + if (webhookUrl) { + await this.forwardSocketEvent(webhookUrl, { + type: "socket_event", + body: body as Record, + timestamp: Date.now(), + }); + } else { + this.routeSocketEvent(body as Record, 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; } @@ -1430,7 +1794,7 @@ export class SlackAdapter implements Adapter { text: string, skipSelfMention: boolean ): Promise { - const mentionPattern = /<@([A-Z0-9]+)(?:\|[^>]*)?>/g; + const mentionPattern = /<@([A-Z0-9]+)(?:\|[^<>]*)?>/g; const userIds = new Set(); let match: RegExpExecArray | null = mentionPattern.exec(text); while (match) { @@ -1460,7 +1824,7 @@ export class SlackAdapter implements Adapter { const nameMap = new Map(lookups); // Replace <@U123> and <@U123|old> with <@U123|resolvedName> - return text.replace(/<@([A-Z0-9]+)(?:\|[^>]*)?>/g, (_m, uid: string) => { + return text.replace(/<@([A-Z0-9]+)(?:\|[^<>]*)?>/g, (_m, uid: string) => { const name = nameMap.get(uid); return name ? `<@${uid}|${name}>` : `<@${uid}>`; }); @@ -2961,14 +3325,33 @@ export class SlackAdapter implements Adapter { 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 (!signingSecret) { + 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 @@ -2976,6 +3359,8 @@ export function createSlackAdapter( const zeroConfig = !config; const resolved: SlackAdapterConfig = { + appToken, + mode, signingSecret, botToken: config?.botToken ?? @@ -2989,6 +3374,9 @@ export function createSlackAdapter( 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, }; diff --git a/packages/adapter-slack/src/markdown.ts b/packages/adapter-slack/src/markdown.ts index 70313477..49323f95 100644 --- a/packages/adapter-slack/src/markdown.ts +++ b/packages/adapter-slack/src/markdown.ts @@ -76,18 +76,21 @@ export class SlackFormatConverter extends BaseFormatConverter { let markdown = mrkdwn; // User mentions: <@U123|name> -> @name or <@U123> -> @U123 - markdown = markdown.replace(/<@([^|>]+)\|([^>]+)>/g, "@$2"); - markdown = markdown.replace(/<@([^>]+)>/g, "@$1"); + markdown = markdown.replace(/<@([^|<>]+)\|([^<>]+)>/g, "@$2"); + markdown = markdown.replace(/<@([^<>]+)>/g, "@$1"); // Channel mentions: <#C123|name> -> #name - markdown = markdown.replace(/<#[^|>]+\|([^>]+)>/g, "#$1"); - markdown = markdown.replace(/<#([^>]+)>/g, "#$1"); + markdown = markdown.replace(/<#[^|<>]+\|([^<>]+)>/g, "#$1"); + markdown = markdown.replace(/<#([^<>]+)>/g, "#$1"); // Links: -> [text](url) - markdown = markdown.replace(/<(https?:\/\/[^|>]+)\|([^>]+)>/g, "[$2]($1)"); + markdown = markdown.replace( + /<(https?:\/\/[^|<>]+)\|([^<>]+)>/g, + "[$2]($1)" + ); // Bare links: -> url - markdown = markdown.replace(/<(https?:\/\/[^>]+)>/g, "$1"); + markdown = markdown.replace(/<(https?:\/\/[^<>]+)>/g, "$1"); // Bold: *text* -> **text** (but be careful with emphasis) // This is tricky because Slack uses * for bold, not emphasis diff --git a/packages/adapter-slack/tsup.config.ts b/packages/adapter-slack/tsup.config.ts index a1bb0248..c4bd937c 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: true, - external: ["@slack/web-api"], + external: ["@slack/web-api", "@slack/socket-mode"], }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 44b14249..7f33f302 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -372,6 +372,9 @@ importers: '@chat-adapter/shared': specifier: workspace:* version: link:../adapter-shared + '@slack/socket-mode': + specifier: ^2.0.5 + version: 2.0.5 '@slack/web-api': specifier: ^7.11.0 version: 7.13.0 @@ -2512,6 +2515,10 @@ packages: resolution: {integrity: sha512-Wz7QYfPAlG/DR+DfABddUZeNgoeY7d1J39OCR2jR+v7VBsB8ezulDK5szTnDDPDwLH5IWhLvXIHlCFZV7MSKgA==} engines: {node: '>= 18', npm: '>= 8.6.0'} + '@slack/socket-mode@2.0.5': + resolution: {integrity: sha512-VaapvmrAifeFLAFaDPfGhEwwunTKsI6bQhYzxRXw7BSujZUae5sANO76WqlVsLXuhVtCVrBWPiS2snAQR2RHJQ==} + engines: {node: '>= 18', npm: '>= 8.6.0'} + '@slack/types@2.19.0': resolution: {integrity: sha512-7+QZ38HGcNh/b/7MpvPG6jnw7mliV6UmrquJLqgdxkzJgQEYUcEztvFWRU49z0x4vthF0ixL5lTK601AXrS8IA==} engines: {node: '>= 12.13.0', npm: '>= 6.12.0'} @@ -7765,6 +7772,19 @@ snapshots: dependencies: '@types/node': 25.3.2 + '@slack/socket-mode@2.0.5': + dependencies: + '@slack/logger': 4.0.0 + '@slack/web-api': 7.13.0 + '@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.19.0': {} '@slack/web-api@7.13.0':