diff --git a/apps/docs/content/docs/adapters/slack.mdx b/apps/docs/content/docs/adapters/slack.mdx index 6148f791..5c617b11 100644 --- a/apps/docs/content/docs/adapters/slack.mdx +++ b/apps/docs/content/docs/adapters/slack.mdx @@ -83,6 +83,47 @@ await slackAdapter.withBotToken(install.botToken, async () => { `withBotToken` uses `AsyncLocalStorage` under the hood, so concurrent calls with different tokens are isolated. +### Socket Mode (no public webhook URL) + +If your environment cannot expose public inbound webhooks, use Slack Socket Mode and bridge envelopes into `bot.webhooks.slack`: + +```typescript title="lib/slack-socket-mode.ts" lineNumbers +import { bot } from "@/lib/bot"; +import { createSlackSocketModeBridge } from "@chat-adapter/slack"; + +const slack = bot.getAdapter("slack"); +const bridge = createSlackSocketModeBridge({ + // Uses SLACK_APP_TOKEN by default (xapp-...) + adapter: slack, +}); + +await bot.initialize(); +await bridge.start(); + +process.on("SIGTERM", async () => { + await bridge.stop(); + await bot.shutdown(); +}); +``` + +Required environment variables: + +```bash title=".env.local" +SLACK_APP_TOKEN=xapp-... +SLACK_BOT_TOKEN=xoxb-... +``` + +`SLACK_SIGNING_SECRET` is only required when you're also handling public webhook requests. + +Manifest changes for Socket Mode: + +```yaml title="slack-manifest.yml" +settings: + socket_mode_enabled: true +``` + +`SlackSocketModeBridge` preserves modal submission responses by waiting for `view_submission` handler results before acking the Socket Mode envelope. + ### Removing installations ```typescript title="lib/bot.ts" @@ -189,13 +230,13 @@ All options are auto-detected from environment variables when not provided. You | `encryptionKey` | No | AES-256-GCM key for encrypting stored tokens. Auto-detected from `SLACK_ENCRYPTION_KEY` | | `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 ingress, but optional when using Socket Mode-only ingress via `SlackSocketModeBridge`. ## Environment variables ```bash title=".env.local" SLACK_BOT_TOKEN=xoxb-... # Single-workspace only -SLACK_SIGNING_SECRET=... +SLACK_SIGNING_SECRET=... # Required for webhook ingress SLACK_CLIENT_ID=... # Multi-workspace only SLACK_CLIENT_SECRET=... # Multi-workspace only SLACK_ENCRYPTION_KEY=... # Optional, for token encryption diff --git a/examples/nextjs-chat/.env.example b/examples/nextjs-chat/.env.example index 6194bda4..f552b4b4 100644 --- a/examples/nextjs-chat/.env.example +++ b/examples/nextjs-chat/.env.example @@ -4,7 +4,8 @@ BOT_USERNAME=mybot # Slack (optional) # Single-workspace mode (hardcoded bot token): # SLACK_BOT_TOKEN=xoxb-your-bot-token -# SLACK_SIGNING_SECRET=your-signing-secret +# SLACK_SIGNING_SECRET=your-signing-secret # required for public webhook ingress +# SLACK_APP_TOKEN=xapp-your-app-token # required for Socket Mode worker # Multi-workspace mode (OAuth - use instead of SLACK_BOT_TOKEN): # SLACK_CLIENT_ID=your-client-id # SLACK_CLIENT_SECRET=your-client-secret diff --git a/examples/nextjs-chat/README.md b/examples/nextjs-chat/README.md index b7d09bae..741969c4 100644 --- a/examples/nextjs-chat/README.md +++ b/examples/nextjs-chat/README.md @@ -35,6 +35,20 @@ The app runs at `http://localhost:3000`. Platform webhooks should point to `/api > For local development with real webhooks, use a tunneling tool like [ngrok](https://ngrok.com) or [`localtunnel`](https://github.com/localtunnel/localtunnel). +### Slack Socket Mode (no public webhook URL) + +If your company cannot expose public webhooks, run Slack via Socket Mode: + +1. In Slack app settings, enable Socket Mode and create an app token (`xapp-...`) with `connections:write`. +2. Set `SLACK_APP_TOKEN` and Slack bot auth vars in `.env.local`. +3. Start the worker: + +```bash +pnpm slack:socket-mode +``` + +This worker opens the Socket Mode WebSocket and forwards envelopes directly to the Slack adapter. + ## What it demonstrates - **Event handlers** — mentions, thread subscriptions, pattern matching, reactions @@ -63,6 +77,8 @@ src/ │ ├── bot.tsx # Bot logic and handlers │ ├── adapters.ts # Adapter initialization │ └── recorder.ts # Webhook recording system +├── scripts/ +│ └── slack-socket-mode.ts # Slack Socket Mode worker (no public webhook) └── middleware.ts # Preview branch proxy ``` @@ -74,7 +90,8 @@ Copy `.env.example` for the full list. At minimum, set `BOT_USERNAME` and creden |----------|-------------| | `BOT_USERNAME` | Bot display name | | `SLACK_BOT_TOKEN` | Slack bot token (single-workspace mode) | -| `SLACK_SIGNING_SECRET` | Slack request verification | +| `SLACK_SIGNING_SECRET` | Slack request verification (webhook ingress only) | +| `SLACK_APP_TOKEN` | Slack Socket Mode app token (`xapp-...`) | | `TEAMS_APP_ID` | Teams app ID | | `TEAMS_APP_PASSWORD` | Teams app password | | `GOOGLE_CHAT_CREDENTIALS` | Google Chat service account JSON | diff --git a/examples/nextjs-chat/package.json b/examples/nextjs-chat/package.json index e2b879c7..2226b77c 100644 --- a/examples/nextjs-chat/package.json +++ b/examples/nextjs-chat/package.json @@ -2,10 +2,12 @@ "name": "example-nextjs-chat", "version": "0.1.0", "private": true, + "type": "module", "scripts": { "dev": "next dev --inspect", "build": "next build", "start": "next start", + "slack:socket-mode": "node --import tsx src/scripts/slack-socket-mode.ts", "typecheck": "tsc --noEmit", "recording:list": "tsx src/lib/recorder.ts --list", "recording:export": "tsx src/lib/recorder.ts" diff --git a/examples/nextjs-chat/src/lib/adapters.ts b/examples/nextjs-chat/src/lib/adapters.ts index 4fba55f9..e90a294b 100644 --- a/examples/nextjs-chat/src/lib/adapters.ts +++ b/examples/nextjs-chat/src/lib/adapters.ts @@ -107,10 +107,14 @@ export function buildAdapters(): Adapters { ); } - // Slack adapter (optional) - env vars: SLACK_SIGNING_SECRET + (SLACK_BOT_TOKEN or SLACK_CLIENT_ID/SECRET) - if (process.env.SLACK_SIGNING_SECRET) { + // Slack adapter (optional) - enable if webhook secret or Socket Mode app token is configured + if (process.env.SLACK_SIGNING_SECRET || process.env.SLACK_APP_TOKEN) { adapters.slack = withRecording( createSlackAdapter({ + botToken: process.env.SLACK_BOT_TOKEN, + clientId: process.env.SLACK_CLIENT_ID, + clientSecret: process.env.SLACK_CLIENT_SECRET, + signingSecret: process.env.SLACK_SIGNING_SECRET, userName: "Chat SDK Bot", logger: logger.child("slack"), }), diff --git a/examples/nextjs-chat/src/scripts/slack-socket-mode.ts b/examples/nextjs-chat/src/scripts/slack-socket-mode.ts new file mode 100644 index 00000000..c5a1907a --- /dev/null +++ b/examples/nextjs-chat/src/scripts/slack-socket-mode.ts @@ -0,0 +1,64 @@ +import { createSlackSocketModeBridge } from "@chat-adapter/slack"; +import { config as loadEnv } from "dotenv"; + +// Load local env files when running outside Next.js runtime. +loadEnv({ path: ".env.local" }); +loadEnv(); + +function requireEnv(name: string): void { + if (!process.env[name]) { + throw new Error(`Missing required environment variable: ${name}`); + } +} + +async function main(): Promise { + requireEnv("SLACK_APP_TOKEN"); + + const { bot } = await import("../lib/bot"); + + const slack = bot.getAdapter("slack"); + if (!slack) { + throw new Error( + "Slack adapter is not configured. Set SLACK_BOT_TOKEN (or OAuth vars) and SLACK_APP_TOKEN." + ); + } + + await bot.initialize(); + + const bridge = createSlackSocketModeBridge({ + appToken: process.env.SLACK_APP_TOKEN, + adapter: slack, + }); + + await bridge.start(); + console.log("[slack-socket-mode] Bridge started. Press Ctrl+C to stop."); + + let shuttingDown = false; + const shutdown = async (signal: string): Promise => { + if (shuttingDown) { + return; + } + shuttingDown = true; + + console.log(`[slack-socket-mode] Received ${signal}. Shutting down...`); + await bridge.stop(); + await bot.shutdown(); + process.exit(0); + }; + + process.on("SIGINT", () => { + void shutdown("SIGINT"); + }); + + process.on("SIGTERM", () => { + void shutdown("SIGTERM"); + }); + + // Keep process alive while Socket Mode connection is active. + await new Promise(() => {}); +} + +void main().catch((error) => { + console.error("[slack-socket-mode] Failed to start:", error); + process.exit(1); +}); diff --git a/examples/nextjs-chat/tsconfig.json b/examples/nextjs-chat/tsconfig.json index ea50f6f9..b29d802c 100644 --- a/examples/nextjs-chat/tsconfig.json +++ b/examples/nextjs-chat/tsconfig.json @@ -22,10 +22,13 @@ "paths": { "@/*": ["./src/*"], "chat": ["../../packages/chat/src/index.ts"], + "@chat-adapter/shared": ["../../packages/adapter-shared/src/index.ts"], "@chat-adapter/slack": ["../../packages/adapter-slack/src/index.ts"], "@chat-adapter/gchat": ["../../packages/adapter-gchat/src/index.ts"], "@chat-adapter/teams": ["../../packages/adapter-teams/src/index.ts"], "@chat-adapter/discord": ["../../packages/adapter-discord/src/index.ts"], + "@chat-adapter/github": ["../../packages/adapter-github/src/index.ts"], + "@chat-adapter/linear": ["../../packages/adapter-linear/src/index.ts"], "@chat-adapter/state-redis": ["../../packages/state-redis/src/index.ts"], "@chat-adapter/state-memory": ["../../packages/state-memory/src/index.ts"] } diff --git a/packages/adapter-slack/README.md b/packages/adapter-slack/README.md index eba77fe8..7a9ce6f1 100644 --- a/packages/adapter-slack/README.md +++ b/packages/adapter-slack/README.md @@ -32,6 +32,24 @@ bot.onNewMention(async (thread, message) => { }); ``` +## Socket Mode (no public webhooks) + +```typescript +import { createSlackSocketModeBridge } from "@chat-adapter/slack"; + +const slack = bot.getAdapter("slack"); +const bridge = createSlackSocketModeBridge({ + adapter: slack, +}); + +await bot.initialize(); +await bridge.start(); +``` + +Set `SLACK_APP_TOKEN` (`xapp-...`) and enable `socket_mode_enabled: true` in your Slack app settings. + +`SLACK_SIGNING_SECRET` is only required when you also expose public webhook endpoints. + ## Documentation Full setup instructions, configuration reference, and features at [chat-sdk.dev/docs/adapters/slack](https://chat-sdk.dev/docs/adapters/slack). diff --git a/packages/adapter-slack/src/index.test.ts b/packages/adapter-slack/src/index.test.ts index 55eb0fa9..92f8d297 100644 --- a/packages/adapter-slack/src/index.test.ts +++ b/packages/adapter-slack/src/index.test.ts @@ -93,6 +93,14 @@ describe("createSlackAdapter", () => { }); expect(adapter.botUserId).toBe("U12345"); }); + + it("allows creating an adapter without signingSecret for Socket Mode-only ingress", () => { + const adapter = createSlackAdapter({ + botToken: "xoxb-test-token", + logger: mockLogger, + }); + expect(adapter).toBeInstanceOf(SlackAdapter); + }); }); // ============================================================================ @@ -261,6 +269,43 @@ describe("handleWebhook - signature verification", () => { const response = await adapter.handleWebhook(request); expect(response.status).toBe(200); }); + + it("returns 500 when signingSecret is not configured", async () => { + const noSecretAdapter = createSlackAdapter({ + botToken: "xoxb-test-token", + logger: mockLogger, + }); + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "url_verification" }), + }); + + const response = await noSecretAdapter.handleWebhook(request); + expect(response.status).toBe(500); + }); + + it("processes Socket Mode envelopes without signingSecret", async () => { + const noSecretAdapter = createSlackAdapter({ + botToken: "xoxb-test-token", + logger: mockLogger, + }); + + const response = await noSecretAdapter.handleSocketModeEnvelope({ + type: "interactive", + payload: { + type: "block_actions", + user: { id: "U123", username: "tester" }, + container: { type: "message", message_ts: "123.456", channel_id: "C1" }, + channel: { id: "C1", name: "general" }, + message: { ts: "123.456" }, + actions: [{ type: "button", action_id: "a1", value: "v1" }], + trigger_id: "trig123", + }, + }); + + expect(response.status).toBe(200); + }); }); // ============================================================================ diff --git a/packages/adapter-slack/src/index.ts b/packages/adapter-slack/src/index.ts index dacacb29..14bbb760 100644 --- a/packages/adapter-slack/src/index.ts +++ b/packages/adapter-slack/src/index.ts @@ -81,8 +81,8 @@ export interface SlackAdapterConfig { installationKeyPrefix?: string; /** Logger instance for error reporting */ logger: Logger; - /** Signing secret for webhook verification */ - signingSecret: string; + /** Signing secret for webhook verification (required for public webhook ingress) */ + signingSecret?: string; /** Override bot username (optional) */ userName?: string; } @@ -205,6 +205,12 @@ interface SlackWebhookPayload { type: string; } +/** Slack Socket Mode envelope payload supported by the adapter */ +export interface SlackSocketModeEnvelopePayload { + payload?: unknown; + type: "events_api" | "interactive" | "slash_commands"; +} + /** Slack interactive payload (block_actions) for button clicks */ interface SlackBlockActionsPayload { actions: Array<{ @@ -291,7 +297,7 @@ export class SlackAdapter implements Adapter { readonly userName: string; private readonly client: WebClient; - private readonly signingSecret: string; + private readonly signingSecret?: string; private readonly defaultBotToken: string | undefined; private chat: ChatInstance | null = null; private readonly logger: Logger; @@ -659,6 +665,13 @@ export class SlackAdapter implements Adapter { const body = await request.text(); this.logger.debug("Slack webhook raw body", { body }); + if (!this.signingSecret) { + this.logger.warn( + "Slack webhook rejected: signingSecret is not configured on adapter" + ); + return new Response("Signing secret not configured", { status: 500 }); + } + // Verify request signature const timestamp = request.headers.get("x-slack-request-timestamp"); const signature = request.headers.get("x-slack-signature"); @@ -669,6 +682,58 @@ export class SlackAdapter implements Adapter { // Check if this is a form-urlencoded payload const contentType = request.headers.get("content-type") || ""; + return this.handleIncomingPayload(body, contentType, options); + } + + /** + * Handle Slack Socket Mode envelopes directly (no HTTP signature required). + */ + async handleSocketModeEnvelope( + envelope: SlackSocketModeEnvelopePayload, + options?: WebhookOptions + ): Promise { + const contentType = + envelope.type === "events_api" + ? "application/json" + : "application/x-www-form-urlencoded"; + + let body: string; + if (envelope.type === "events_api") { + body = JSON.stringify(envelope.payload ?? {}); + } else if (envelope.type === "interactive") { + body = new URLSearchParams({ + payload: JSON.stringify(envelope.payload ?? {}), + }).toString(); + } else { + const params = new URLSearchParams(); + const payload = envelope.payload; + if (payload && typeof payload === "object") { + for (const [key, value] of Object.entries(payload)) { + if (value === undefined || value === null) { + continue; + } + if ( + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ) { + params.set(key, String(value)); + } else { + params.set(key, JSON.stringify(value)); + } + } + } + body = params.toString(); + } + + return this.handleIncomingPayload(body, contentType, options); + } + + private async handleIncomingPayload( + body: string, + contentType: string, + options?: WebhookOptions + ): Promise { if (contentType.includes("application/x-www-form-urlencoded")) { const params = new URLSearchParams(body); if (params.has("command") && !params.has("payload")) { @@ -1074,7 +1139,7 @@ export class SlackAdapter implements Adapter { timestamp: string | null, signature: string | null ): boolean { - if (!(timestamp && signature)) { + if (!(this.signingSecret && timestamp && signature)) { return false; } @@ -2963,12 +3028,6 @@ export function createSlackAdapter( ): SlackAdapter { const signingSecret = config?.signingSecret ?? process.env.SLACK_SIGNING_SECRET; - if (!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 @@ -3005,3 +3064,8 @@ export { SlackFormatConverter, SlackFormatConverter as SlackMarkdownConverter, } from "./markdown"; +export { + createSlackSocketModeBridge, + SlackSocketModeBridge, +} from "./socket-mode"; +export type { SlackSocketModeBridgeConfig } from "./socket-mode"; diff --git a/packages/adapter-slack/src/socket-mode.ts b/packages/adapter-slack/src/socket-mode.ts new file mode 100644 index 00000000..a2883f73 --- /dev/null +++ b/packages/adapter-slack/src/socket-mode.ts @@ -0,0 +1,522 @@ +import { createHmac } from "node:crypto"; +import { ValidationError } from "@chat-adapter/shared"; +import { WebClient } from "@slack/web-api"; +import { ConsoleLogger } from "chat"; +import type { Logger, WebhookOptions } from "chat"; + +interface SlackSocketModeEnvelope { + envelope_id: string; + type: string; + payload?: unknown; + accepts_response_payload?: boolean; +} + +interface SignedRequestInput { + body: string; + contentType: string; +} + +interface SlackSocketModeIngressAdapter { + handleSocketModeEnvelope( + envelope: { + payload?: unknown; + type: "events_api" | "interactive" | "slash_commands"; + }, + options?: WebhookOptions + ): Promise; +} + +export interface SlackSocketModeBridgeConfig { + /** Slack app-level token (`xapp-...`). Defaults to SLACK_APP_TOKEN. */ + appToken?: string; + /** Slack signing secret. Required only when using webhookHandler mode. */ + signingSecret?: string; + /** Slack adapter instance with direct Socket Mode envelope handling. */ + adapter?: SlackSocketModeIngressAdapter; + /** Handler to process translated Socket Mode payloads (typically bot.webhooks.slack). */ + webhookHandler?: ( + request: Request, + options?: WebhookOptions + ) => Promise; + /** Optional waitUntil implementation passed through to webhook handling. */ + waitUntil?: (task: Promise) => void; + /** Optional logger; defaults to ConsoleLogger("info").child("slack-socket-mode"). */ + logger?: Logger; + /** Reconnect automatically after disconnects. Defaults to true. */ + autoReconnect?: boolean; + /** Initial reconnect delay in milliseconds. Defaults to 1000ms. */ + reconnectDelayMs?: number; + /** Maximum reconnect delay in milliseconds. Defaults to 30000ms. */ + maxReconnectDelayMs?: number; +} + +/** + * Bridge Slack Socket Mode envelopes into Chat SDK's Slack webhook handler. + * + * This lets you run Slack integrations without exposing a public webhook URL. + */ +export class SlackSocketModeBridge { + private readonly appToken: string; + private readonly signingSecret?: string; + private readonly adapter?: SlackSocketModeIngressAdapter; + private readonly webhookHandler?: ( + request: Request, + options?: WebhookOptions + ) => Promise; + private readonly waitUntil?: (task: Promise) => void; + private readonly logger: Logger; + private readonly autoReconnect: boolean; + private readonly reconnectDelayMs: number; + private readonly maxReconnectDelayMs: number; + + private readonly webClient: WebClient; + private socket: WebSocket | null = null; + private running = false; + private reconnectAttempt = 0; + private reconnectTimer: NodeJS.Timeout | null = null; + + constructor(config: SlackSocketModeBridgeConfig) { + const appToken = config.appToken ?? process.env.SLACK_APP_TOKEN; + if (!appToken) { + throw new ValidationError( + "slack", + "appToken is required. Set SLACK_APP_TOKEN or provide it in config." + ); + } + + if (!(config.adapter || config.webhookHandler)) { + throw new ValidationError( + "slack", + "Either adapter or webhookHandler is required for Socket Mode bridging." + ); + } + + const signingSecret = + config.signingSecret ?? process.env.SLACK_SIGNING_SECRET; + if (!config.adapter && !signingSecret) { + throw new ValidationError( + "slack", + "signingSecret is required in webhookHandler mode. Set SLACK_SIGNING_SECRET or provide it in config." + ); + } + + this.appToken = appToken; + this.signingSecret = signingSecret; + this.adapter = config.adapter; + this.webhookHandler = config.webhookHandler; + this.waitUntil = config.waitUntil; + this.logger = + config.logger ?? new ConsoleLogger("info").child("slack-socket-mode"); + this.autoReconnect = config.autoReconnect ?? true; + this.reconnectDelayMs = config.reconnectDelayMs ?? 1000; + this.maxReconnectDelayMs = config.maxReconnectDelayMs ?? 30000; + + this.webClient = new WebClient(this.appToken); + } + + /** Whether the bridge currently intends to stay connected. */ + get isRunning(): boolean { + return this.running; + } + + /** Open a Socket Mode connection and begin forwarding events. */ + async start(): Promise { + if (this.running) { + this.logger.debug("Socket Mode bridge already running"); + return; + } + + this.running = true; + this.reconnectAttempt = 0; + + this.logger.info("Starting Slack Socket Mode bridge"); + await this.connect(); + } + + /** Stop forwarding and close the Socket Mode connection. */ + async stop(): Promise { + if (!this.running) { + return; + } + + this.running = false; + this.clearReconnectTimer(); + + if (this.socket) { + const socket = this.socket; + this.socket = null; + + await new Promise((resolve) => { + socket.addEventListener("close", () => resolve(), { once: true }); + socket.close(); + }); + } + + this.logger.info("Stopped Slack Socket Mode bridge"); + } + + private async connect(): Promise { + if (!this.running) { + return; + } + + this.clearReconnectTimer(); + + try { + const openResult = await this.webClient.apps.connections.open(); + const socketUrl = openResult.url; + if (!socketUrl) { + throw new Error("apps.connections.open did not return a socket URL"); + } + + this.logger.info("Opening Slack Socket Mode connection"); + const socket = new WebSocket(socketUrl); + this.socket = socket; + + socket.addEventListener("message", (event: Event) => { + const data = (event as Event & { data?: unknown }).data; + void this.handleRawMessage(data); + }); + + socket.addEventListener("error", (event: Event) => { + this.logger.warn("Socket Mode connection error", { event }); + }); + + socket.addEventListener("close", (event: Event) => { + const closeEvent = event as Event & { code?: number; reason?: string }; + this.logger.warn("Socket Mode connection closed", { + code: closeEvent.code, + reason: closeEvent.reason, + }); + + if (this.socket === socket) { + this.socket = null; + } + + if (this.running && this.autoReconnect) { + this.scheduleReconnect(); + } + }); + + await new Promise((resolve, reject) => { + const handleOpen = (): void => { + cleanup(); + resolve(); + }; + const handleError = (): void => { + cleanup(); + reject(new Error("Socket Mode failed to open connection")); + }; + const cleanup = (): void => { + socket.removeEventListener("open", handleOpen); + socket.removeEventListener("error", handleError); + }; + + socket.addEventListener("open", handleOpen); + socket.addEventListener("error", handleError); + }); + + this.reconnectAttempt = 0; + this.logger.info("Slack Socket Mode connected"); + } catch (error) { + this.logger.error("Failed to connect Slack Socket Mode", { error }); + if (this.running && this.autoReconnect) { + this.scheduleReconnect(); + } + } + } + + private scheduleReconnect(): void { + if (!this.running || this.reconnectTimer) { + return; + } + + this.reconnectAttempt += 1; + const backoff = Math.min( + this.reconnectDelayMs * 2 ** (this.reconnectAttempt - 1), + this.maxReconnectDelayMs + ); + + this.logger.info("Scheduling Socket Mode reconnect", { + attempt: this.reconnectAttempt, + delayMs: backoff, + }); + + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + void this.connect(); + }, backoff); + } + + private clearReconnectTimer(): void { + if (!this.reconnectTimer) { + return; + } + + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + + private async handleRawMessage(raw: unknown): Promise { + const message = await this.rawDataToString(raw); + if (!message) { + return; + } + + let parsed: unknown; + try { + parsed = JSON.parse(message); + } catch (error) { + this.logger.warn("Ignoring non-JSON Socket Mode payload", { error }); + return; + } + + if (!isObject(parsed)) { + return; + } + + if (parsed.type === "hello") { + this.logger.debug("Socket Mode hello received"); + return; + } + + if (parsed.type === "disconnect") { + this.logger.warn("Socket Mode disconnect requested", { + reason: parsed.reason, + debugInfo: parsed.debug_info, + }); + this.socket?.close(); + return; + } + + if (!this.isEnvelope(parsed)) { + this.logger.debug("Ignoring unknown Socket Mode message", { + type: parsed.type, + }); + return; + } + + await this.handleEnvelope(parsed); + } + + private async handleEnvelope(envelope: SlackSocketModeEnvelope): Promise { + const requestData = this.mapEnvelopeToRequest(envelope); + if (!requestData) { + this.sendAck(envelope.envelope_id); + this.logger.debug("Ignoring unsupported Socket Mode envelope", { + type: envelope.type, + }); + return; + } + + if (this.shouldWaitForWebhookResponse(envelope)) { + const response = await this.forwardEnvelope(envelope, requestData); + const ackPayload = await this.responseToAckPayload(response); + this.sendAck(envelope.envelope_id, ackPayload); + return; + } + + this.sendAck(envelope.envelope_id); + + void this.forwardEnvelope(envelope, requestData).catch((error) => { + this.logger.error("Socket Mode webhook forwarding failed", { error }); + }); + } + + private forwardEnvelope( + envelope: SlackSocketModeEnvelope, + requestData: SignedRequestInput + ): Promise { + if (this.adapter) { + return this.adapter.handleSocketModeEnvelope( + { + type: envelope.type as "events_api" | "interactive" | "slash_commands", + payload: envelope.payload, + }, + { waitUntil: this.waitUntil } + ); + } + + if (!this.webhookHandler) { + throw new Error("webhookHandler is not configured"); + } + + const request = this.createSignedRequest(requestData); + return this.webhookHandler(request, { waitUntil: this.waitUntil }); + } + + private shouldWaitForWebhookResponse( + envelope: SlackSocketModeEnvelope + ): boolean { + if (!envelope.accepts_response_payload) { + return false; + } + + if (envelope.type !== "interactive" || !isObject(envelope.payload)) { + return false; + } + + return envelope.payload.type === "view_submission"; + } + + private mapEnvelopeToRequest( + envelope: SlackSocketModeEnvelope + ): SignedRequestInput | null { + switch (envelope.type) { + case "events_api": { + return { + body: JSON.stringify(envelope.payload ?? {}), + contentType: "application/json", + }; + } + case "interactive": { + return { + body: new URLSearchParams({ + payload: JSON.stringify(envelope.payload ?? {}), + }).toString(), + contentType: "application/x-www-form-urlencoded", + }; + } + case "slash_commands": { + if (!isObject(envelope.payload)) { + return null; + } + + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(envelope.payload)) { + if (value === null || value === undefined) { + continue; + } + + if ( + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ) { + params.set(key, String(value)); + continue; + } + + params.set(key, JSON.stringify(value)); + } + + return { + body: params.toString(), + contentType: "application/x-www-form-urlencoded", + }; + } + default: + return null; + } + } + + private createSignedRequest(input: SignedRequestInput): Request { + if (!this.signingSecret) { + throw new Error("signingSecret is required to create signed requests"); + } + + const timestamp = String(Math.floor(Date.now() / 1000)); + const signatureBaseString = `v0:${timestamp}:${input.body}`; + const signature = + "v0=" + + createHmac("sha256", this.signingSecret) + .update(signatureBaseString) + .digest("hex"); + + return new Request("http://chat-sdk.local/slack/socket-mode", { + method: "POST", + headers: { + "content-type": input.contentType, + "x-slack-request-timestamp": timestamp, + "x-slack-signature": signature, + }, + body: input.body, + }); + } + + private async responseToAckPayload( + response: Response + ): Promise | undefined> { + const text = await response.text(); + if (!text) { + return undefined; + } + + try { + const parsed = JSON.parse(text) as unknown; + if (!isObject(parsed)) { + return undefined; + } + + return Object.keys(parsed).length > 0 ? parsed : undefined; + } catch { + this.logger.warn("Ignoring non-JSON webhook response for Socket Mode ack"); + return undefined; + } + } + + private sendAck(envelopeId: string, payload?: Record): void { + if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { + this.logger.warn("Cannot send Socket Mode ack: socket is not open", { + envelopeId, + }); + return; + } + + const ack: Record = { envelope_id: envelopeId }; + if (payload) { + ack.payload = payload; + } + + try { + this.socket.send(JSON.stringify(ack)); + } catch (error) { + this.logger.error("Failed sending Socket Mode ack", { + envelopeId, + error, + }); + } + } + + private isEnvelope(value: unknown): value is SlackSocketModeEnvelope { + if (!isObject(value)) { + return false; + } + + return ( + typeof value.envelope_id === "string" && typeof value.type === "string" + ); + } + + private async rawDataToString(raw: unknown): Promise { + if (typeof raw === "string") { + return raw; + } + + if (raw instanceof ArrayBuffer) { + return Buffer.from(raw).toString("utf8"); + } + + if (ArrayBuffer.isView(raw)) { + return Buffer.from(raw.buffer, raw.byteOffset, raw.byteLength).toString( + "utf8" + ); + } + + if (raw instanceof Blob) { + return await raw.text(); + } + + return String(raw); + } +} + +export function createSlackSocketModeBridge( + config: SlackSocketModeBridgeConfig +): SlackSocketModeBridge { + return new SlackSocketModeBridge(config); +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null; +}