Skip to content
Draft
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
50 changes: 46 additions & 4 deletions apps/docs/content/docs/adapters/slack.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down
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 * * * *"
}
]
}
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.11.0",
"chat": "workspace:*"
},
Expand Down
Loading