Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
45 changes: 43 additions & 2 deletions apps/docs/content/docs/adapters/slack.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion examples/nextjs-chat/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 18 additions & 1 deletion examples/nextjs-chat/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
```

Expand All @@ -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 |
Expand Down
2 changes: 2 additions & 0 deletions examples/nextjs-chat/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
8 changes: 6 additions & 2 deletions examples/nextjs-chat/src/lib/adapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
}),
Expand Down
64 changes: 64 additions & 0 deletions examples/nextjs-chat/src/scripts/slack-socket-mode.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> => {
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>(() => {});
}

void main().catch((error) => {
console.error("[slack-socket-mode] Failed to start:", error);
process.exit(1);
});
3 changes: 3 additions & 0 deletions examples/nextjs-chat/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
}
Expand Down
18 changes: 18 additions & 0 deletions packages/adapter-slack/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
45 changes: 45 additions & 0 deletions packages/adapter-slack/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});

// ============================================================================
Expand Down Expand Up @@ -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);
});
});

// ============================================================================
Expand Down
Loading