Skip to content

Commit 7e90d9c

Browse files
haydenbleaselvercel[bot]claudedancer
authored
Add Slack Socket Mode support (#162)
* Add slack/socket mode dependency * Update config types and SlackAdapter class * Add socket mode methods, extract interactive dispatch * Update createSlackAdapter factory function * Write tests for socket mode * Create slack-socket-mode.md * Run fix * Fix polynomial regex issues * Fix: Floating promises in `routeSocketEvent` for slash commands and interactive payloads can cause unhandled promise rejections that crash the Node.js process. This commit fixes the issue reported at packages/adapter-slack/src/index.ts:1152 **Bug Analysis:** In `routeSocketEvent` (line 1150), which is a synchronous `void` method, two async operations produce floating promises: 1. `this.handleSlashCommand(params)` (line 1165) - `handleSlashCommand` is `async` and always returns a `Promise<Response>`. It calls `await this.lookupUser(userId)` which internally calls `await this.chat.getState().get()` (before the try/catch around the API call), and `this.chat.processSlashCommand()`. Any of these could throw. 2. `this.dispatchInteractivePayload(payload)` (line 1172) - Returns `Response | Promise<Response>`. When the payload type is `view_submission`, it delegates to `async handleViewSubmission()`, which calls `await this.chat.processModalSubmit()` and accesses `payload.view.state.values` (which could throw on malformed payloads). Since `routeSocketEvent` is synchronous (`void` return type) and called from a sync context within the socket mode event handler (after `await ack()` has already completed), these returned promises are fire-and-forget. If any reject, it triggers an unhandled promise rejection, which in Node.js 15+ terminates the process by default. In contrast, in the webhook code path (`handleWebhook`), these same methods are always `return`-ed from async functions, so their promises are properly chained to the caller. **Fix:** Added `.catch()` handlers to both floating promises: 1. For `handleSlashCommand`: Added `.catch()` that logs the error via `this.logger.error`. 2. For `dispatchInteractivePayload`: Since it returns `Response | Promise<Response>` (only a Promise for `view_submission`), used `instanceof Promise` to conditionally attach a `.catch()` handler only when the result is a Promise. This approach was chosen over making `routeSocketEvent` async because: (a) it doesn't change the method signature, (b) the caller doesn't need to await it (the ack has already been sent), and (c) errors are logged rather than silently swallowed. Co-authored-by: Vercel <vercel[bot]@users.noreply.github.com> Co-authored-by: haydenbleasel <hello@haydenbleasel.com> * Add socket mode forwarding support to Slack adapter - Export SlackForwardedSocketEvent type - Add x-slack-socket-token check at top of handleWebhook() for forwarded events - Update routeSocketEvent() to accept WebhookOptions and use waitUntil - Add startSocketModeListener(), runSocketModeListener(), forwardSocketEvent() Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add tests for socket mode forwarding - Forwarded event accepted/rejected based on appToken - Bypasses signature verification for forwarded events - Options passthrough to handlers - startSocketModeListener returns 200/500 appropriately Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add socket mode cron route and vercel config - New /api/slack/socket-mode route using createPersistentListener - Mirrors Discord gateway pattern (CRON_SECRET auth, Redis coordination) - Cron runs every 9 min, listener duration 10 min Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix signingSecret defaulting to empty string in socket mode Make signingSecret optional (string | undefined) instead of falling back to "". verifySignature now returns false when no secret is configured, preventing HMAC with an empty key from silently passing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Wrap event_callback in try-catch in routeSocketEvent Sync errors from processEventPayload were silently dropped in socket mode. Wrap with try-catch for parity with slash_commands and interactive cases. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Use dedicated socketForwardingSecret for forwarding auth Stop using the Slack app-level token (xapp-...) as the bearer token for HTTP forwarding. Adds socketForwardingSecret config option (auto-detected from SLACK_SOCKET_FORWARDING_SECRET) with fallback to appToken for backwards compatibility. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Replace double cast with type guard for socket event body Validate body.event exists and construct a properly typed SlackWebhookPayload instead of using `as unknown as`. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Internalize SlackForwardedSocketEvent type Remove export — only used internally by the forwarding mechanism. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix formatting in socketForwardingSecret check Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add socket mode documentation to Slack adapter README Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(slack): use SDK envelope type for socket mode event routing * fix(slack): pass interactive response through ack in socket mode * feat(chat): add clear modal response action to close entire view stack * chore: update changeset for clear modal action --------- Co-authored-by: Vercel <vercel[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: dancer <josh@afterima.ge>
1 parent 46fc5bb commit 7e90d9c

13 files changed

Lines changed: 1548 additions & 11 deletions

File tree

.changeset/slack-socket-mode.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"chat": minor
3+
"@chat-adapter/slack": minor
4+
---
5+
6+
Add Socket Mode support for environments behind firewalls that can't expose public HTTP endpoints, and add `{ action: "clear" }` modal response to close the entire modal view stack

apps/docs/content/docs/api/chat.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,8 @@ bot.onModalSubmit("feedback", async (event) => {
255255

256256
Returns `ModalResponse | undefined` to control the modal after submission:
257257

258-
- `{ action: "close" }` — close the modal
258+
- `{ action: "close" }` — close the current view (goes back one level in the stack)
259+
- `{ action: "clear" }` — close all views and dismiss the modal entirely
259260
- `{ action: "errors", errors: { fieldId: "message" } }` — show validation errors
260261
- `{ action: "update", modal: ModalElement }` — replace the modal content
261262
- `{ action: "push", modal: ModalElement }` — push a new modal view onto the stack

apps/docs/content/docs/modals.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,8 @@ bot.onModalSubmit("feedback_form", async (event) => {
142142

143143
| Response | Description |
144144
|----------|-------------|
145-
| `undefined` or `{ action: "close" }` | Close the modal |
145+
| `undefined` or `{ action: "close" }` | Close the current view (goes back one level in the stack) |
146+
| `{ action: "clear" }` | Close all views and dismiss the modal entirely |
146147
| `{ action: "errors", errors: { fieldId: "message" } }` | Show validation errors on specific fields |
147148
| `{ action: "update", modal: ModalElement }` | Replace the modal content |
148149
| `{ action: "push", modal: ModalElement }` | Push a new modal view onto the stack |
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { after } from "next/server";
2+
import { bot } from "@/lib/bot";
3+
import { createPersistentListener } from "@/lib/persistent-listener";
4+
5+
export const maxDuration = 800;
6+
7+
// Default listener duration: 10 minutes
8+
const DEFAULT_DURATION_MS = 600 * 1000;
9+
10+
/**
11+
* Persistent listener for Slack Socket Mode.
12+
* Handles cross-instance coordination via Redis pub/sub.
13+
*/
14+
const slackSocketMode = createPersistentListener({
15+
name: "slack-socket-mode",
16+
redisUrl: process.env.REDIS_URL,
17+
defaultDurationMs: DEFAULT_DURATION_MS,
18+
maxDurationMs: DEFAULT_DURATION_MS,
19+
});
20+
21+
/**
22+
* Start the Slack Socket Mode WebSocket listener.
23+
*
24+
* This endpoint is invoked by a Vercel cron job every 9 minutes to maintain
25+
* continuous Socket Mode connectivity. Events are acked immediately and
26+
* forwarded via HTTP POST to the existing webhook endpoint.
27+
*
28+
* Security: Requires CRON_SECRET validation.
29+
*
30+
* Usage: GET /api/slack/socket-mode
31+
* Optional query param: ?duration=600000 (milliseconds, max 600000)
32+
*/
33+
export async function GET(request: Request): Promise<Response> {
34+
const cronSecret = process.env.CRON_SECRET;
35+
if (!cronSecret) {
36+
console.error("[slack-socket-mode] CRON_SECRET not configured");
37+
return new Response("CRON_SECRET not configured", { status: 500 });
38+
}
39+
const authHeader = request.headers.get("authorization");
40+
if (authHeader !== `Bearer ${cronSecret}`) {
41+
console.log("[slack-socket-mode] Unauthorized: invalid CRON_SECRET");
42+
return new Response("Unauthorized", { status: 401 });
43+
}
44+
45+
await bot.initialize();
46+
47+
const slack = bot.getAdapter("slack");
48+
if (!slack) {
49+
console.log("[slack-socket-mode] Slack adapter not configured");
50+
return new Response("Slack adapter not configured", { status: 404 });
51+
}
52+
53+
// Construct webhook URL for forwarding socket events
54+
const baseUrl =
55+
process.env.VERCEL_PROJECT_PRODUCTION_URL ||
56+
process.env.VERCEL_URL ||
57+
process.env.NEXT_PUBLIC_BASE_URL;
58+
let webhookUrl: string | undefined;
59+
if (baseUrl) {
60+
const bypassSecret = process.env.VERCEL_AUTOMATION_BYPASS_SECRET;
61+
const queryParam = bypassSecret
62+
? `?x-vercel-protection-bypass=${bypassSecret}`
63+
: "";
64+
webhookUrl = `https://${baseUrl}/api/webhooks/slack${queryParam}`;
65+
}
66+
67+
return slackSocketMode.start(request, {
68+
afterTask: (task) => after(() => task),
69+
run: async ({ abortSignal, durationMs, listenerId }) => {
70+
console.log(
71+
`[slack-socket-mode] Starting Socket Mode listener: ${listenerId}`,
72+
{
73+
webhookUrl: webhookUrl ? "configured" : "not configured",
74+
durationMs,
75+
}
76+
);
77+
78+
const response = await slack.startSocketModeListener(
79+
{ waitUntil: (task: Promise<unknown>) => after(() => task) },
80+
durationMs,
81+
abortSignal,
82+
webhookUrl
83+
);
84+
85+
console.log(
86+
`[slack-socket-mode] Socket Mode listener ${listenerId} completed with status: ${response.status}`
87+
);
88+
89+
return response;
90+
},
91+
});
92+
}

examples/nextjs-chat/vercel.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44
{
55
"path": "/api/discord/gateway",
66
"schedule": "*/9 * * * *"
7+
},
8+
{
9+
"path": "/api/slack/socket-mode",
10+
"schedule": "*/9 * * * *"
711
}
812
]
913
}

packages/adapter-slack/README.md

Lines changed: 88 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,86 @@ openssl rand -base64 32
102102

103103
When `encryptionKey` is set, `setInstallation()` encrypts the token before storing and `getInstallation()` decrypts it transparently.
104104

105+
## Socket mode
106+
107+
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.
108+
109+
```typescript
110+
import { Chat } from "chat";
111+
import { createSlackAdapter } from "@chat-adapter/slack";
112+
113+
const bot = new Chat({
114+
userName: "mybot",
115+
adapters: {
116+
slack: createSlackAdapter({
117+
mode: "socket",
118+
appToken: process.env.SLACK_APP_TOKEN!,
119+
botToken: process.env.SLACK_BOT_TOKEN!,
120+
}),
121+
},
122+
});
123+
```
124+
125+
### Slack app setup for socket mode
126+
127+
1. Go to your app's settings at [api.slack.com/apps](https://api.slack.com/apps)
128+
2. Navigate to **Socket Mode** and enable it
129+
3. Generate an **App-Level Token** with the `connections:write` scope — this is your `SLACK_APP_TOKEN` (`xapp-...`)
130+
4. Event subscriptions and interactivity still need to be configured, but no public request URL is required
131+
132+
> Socket mode is not compatible with multi-workspace OAuth (`clientId`/`clientSecret`). It's designed for single-workspace deployments.
133+
134+
### Socket mode on serverless (Vercel)
135+
136+
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:
137+
138+
1. A cron job periodically starts a transient socket listener
139+
2. The listener connects via WebSocket, acks events immediately, and forwards them as HTTP requests to your webhook endpoint
140+
3. Your existing webhook route processes the forwarded events normally
141+
142+
```typescript
143+
// api/slack/socket-mode/route.ts
144+
import { after } from "next/server";
145+
import { bot } from "@/lib/bot";
146+
147+
export const maxDuration = 800;
148+
149+
export async function GET(request: Request) {
150+
const authHeader = request.headers.get("authorization");
151+
if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
152+
return new Response("Unauthorized", { status: 401 });
153+
}
154+
155+
await bot.initialize();
156+
157+
const slack = bot.getAdapter("slack");
158+
const webhookUrl = `https://${process.env.VERCEL_URL}/api/webhooks/slack`;
159+
160+
return slack.startSocketModeListener(
161+
{ waitUntil: (task: Promise<unknown>) => after(() => task) },
162+
600_000, // 10 minutes
163+
undefined,
164+
webhookUrl
165+
);
166+
}
167+
```
168+
169+
Schedule the cron job to run every 9 minutes (overlapping with the 10-minute listener duration) to maintain continuous coverage:
170+
171+
```json
172+
// vercel.json
173+
{
174+
"crons": [
175+
{
176+
"path": "/api/slack/socket-mode",
177+
"schedule": "*/9 * * * *"
178+
}
179+
]
180+
}
181+
```
182+
183+
Forwarded events are authenticated using the `socketForwardingSecret` config option (defaults to `SLACK_SOCKET_FORWARDING_SECRET` env var, falling back to `appToken`).
184+
105185
## Slack app setup
106186

107187
### 1. Create a Slack app from manifest
@@ -188,19 +268,25 @@ All options are auto-detected from environment variables when not provided. You
188268
|--------|----------|-------------|
189269
| `botToken` | No | Bot token (`xoxb-...`). Auto-detected from `SLACK_BOT_TOKEN` |
190270
| `signingSecret` | No* | Signing secret for webhook verification. Auto-detected from `SLACK_SIGNING_SECRET` |
271+
| `mode` | No | Connection mode: `"webhook"` (default) or `"socket"` |
272+
| `appToken` | No** | App-level token (`xapp-...`) for socket mode. Auto-detected from `SLACK_APP_TOKEN` |
273+
| `socketForwardingSecret` | No | Shared secret for authenticating forwarded socket events. Auto-detected from `SLACK_SOCKET_FORWARDING_SECRET`, falls back to `appToken` |
191274
| `clientId` | No | App client ID for multi-workspace OAuth. Auto-detected from `SLACK_CLIENT_ID` |
192275
| `clientSecret` | No | App client secret for multi-workspace OAuth. Auto-detected from `SLACK_CLIENT_SECRET` |
193276
| `encryptionKey` | No | AES-256-GCM key for encrypting stored tokens. Auto-detected from `SLACK_ENCRYPTION_KEY` |
194277
| `installationKeyPrefix` | No | Prefix for the state key used to store workspace installations. Defaults to `slack:installation`. The full key is `{prefix}:{teamId}` |
195278
| `logger` | No | Logger instance (defaults to `ConsoleLogger("info")`) |
196279

197-
*`signingSecret` is required — either via config or `SLACK_SIGNING_SECRET` env var.
280+
*`signingSecret` is required for webhook mode — either via config or `SLACK_SIGNING_SECRET` env var.
281+
**`appToken` is required for socket mode — either via config or `SLACK_APP_TOKEN` env var.
198282

199283
## Environment variables
200284

201285
```bash
202286
SLACK_BOT_TOKEN=xoxb-... # Single-workspace only
203-
SLACK_SIGNING_SECRET=...
287+
SLACK_SIGNING_SECRET=... # Required for webhook mode
288+
SLACK_APP_TOKEN=xapp-... # Required for socket mode
289+
SLACK_SOCKET_FORWARDING_SECRET=... # Optional, for socket event forwarding auth
204290
SLACK_CLIENT_ID=... # Multi-workspace only
205291
SLACK_CLIENT_SECRET=... # Multi-workspace only
206292
SLACK_ENCRYPTION_KEY=... # Optional, for token encryption

packages/adapter-slack/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
},
2626
"dependencies": {
2727
"@chat-adapter/shared": "workspace:*",
28+
"@slack/socket-mode": "^2.0.5",
2829
"@slack/web-api": "^7.14.0",
2930
"chat": "workspace:*"
3031
},

0 commit comments

Comments
 (0)