Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/slack-socket-mode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@chat-adapter/slack": minor
---

Add Socket Mode support for environments behind firewalls that can't expose public HTTP endpoints
92 changes: 92 additions & 0 deletions examples/nextjs-chat/src/app/api/slack/socket-mode/route.ts
Original file line number Diff line number Diff line change
@@ -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<Response> {
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<unknown>) => after(() => task) },
durationMs,
abortSignal,
webhookUrl
);

console.log(
`[slack-socket-mode] Socket Mode listener ${listenerId} completed with status: ${response.status}`
);

return response;
},
});
}
4 changes: 4 additions & 0 deletions examples/nextjs-chat/vercel.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
{
"path": "/api/discord/gateway",
"schedule": "*/9 * * * *"
},
{
"path": "/api/slack/socket-mode",
"schedule": "*/9 * * * *"
}
]
}
89 changes: 87 additions & 2 deletions packages/adapter-slack/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,85 @@ 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 { bot, slackAdapter } from "@/lib/bot";

export const maxDuration = 800;

export async function GET(request: Request) {
// Verify cron secret
const authHeader = request.headers.get("authorization");
if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
return new Response("Unauthorized", { status: 401 });
}

await bot.initialize();

const webhookUrl = `${process.env.VERCEL_URL}/api/webhooks/slack`;

await slackAdapter.startSocketModeListener(
{ forwardTo: webhookUrl },
600_000 // 10 minutes
);

return new Response("OK");
}
```

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
Expand Down Expand Up @@ -184,19 +263,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
Expand Down
1 change: 1 addition & 0 deletions packages/adapter-slack/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
},
"dependencies": {
"@chat-adapter/shared": "workspace:*",
"@slack/socket-mode": "^2.0.5",
"@slack/web-api": "^7.14.0",
"chat": "workspace:*"
},
Expand Down
Loading
Loading