feat(slack): add HTTP receiver webhook mode (#1143) - thanks @jdrhyne

Co-authored-by: Jonathan Rhyne <jdrhyne@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-01-18 15:01:04 +00:00
parent e9a08dc507
commit 4726580c7e
11 changed files with 342 additions and 24 deletions

View File

@@ -12,6 +12,7 @@ Docs: https://docs.clawd.bot
- Plugins: route command detection/text chunking helpers through the plugin runtime and drop runtime exports from the SDK. - Plugins: route command detection/text chunking helpers through the plugin runtime and drop runtime exports from the SDK.
- Memory: add native Gemini embeddings provider for memory search. (#1151) - Memory: add native Gemini embeddings provider for memory search. (#1151)
- Agents: add local docs path resolution and include docs/mirror/source/community pointers in the system prompt. - Agents: add local docs path resolution and include docs/mirror/source/community pointers in the system prompt.
- Slack: add HTTP webhook mode via Bolt HTTP receiver for Events API deployments. (#1143) — thanks @jdrhyne.
### Fixes ### Fixes
- Auth profiles: keep auto-pinned preference while allowing rotation on failover; user pins stay locked. (#1138) — thanks @cheeeee. - Auth profiles: keep auto-pinned preference while allowing rotation on failover; user pins stay locked. (#1138) — thanks @cheeeee.

View File

@@ -1,11 +1,13 @@
--- ---
summary: "Slack socket mode setup and Clawdbot config" summary: "Slack setup for socket or HTTP webhook mode"
read_when: "Setting up Slack or debugging Slack socket mode" read_when: "Setting up Slack or debugging Slack socket/HTTP mode"
--- ---
# Slack (socket mode) # Slack
## Quick setup (beginner) ## Socket mode (default)
### Quick setup (beginner)
1) Create a Slack app and enable **Socket Mode**. 1) Create a Slack app and enable **Socket Mode**.
2) Create an **App Token** (`xapp-...`) and **Bot Token** (`xoxb-...`). 2) Create an **App Token** (`xapp-...`) and **Bot Token** (`xoxb-...`).
3) Set tokens for Clawdbot and start the gateway. 3) Set tokens for Clawdbot and start the gateway.
@@ -23,7 +25,7 @@ Minimal config:
} }
``` ```
## Setup ### Setup
1) Create a Slack app (From scratch) in https://api.channels.slack.com/apps. 1) Create a Slack app (From scratch) in https://api.channels.slack.com/apps.
2) **Socket Mode** → toggle on. Then go to **Basic Information****App-Level Tokens****Generate Token and Scopes** with scope `connections:write`. Copy the **App Token** (`xapp-...`). 2) **Socket Mode** → toggle on. Then go to **Basic Information****App-Level Tokens****Generate Token and Scopes** with scope `connections:write`. Copy the **App Token** (`xapp-...`).
3) **OAuth & Permissions** → add bot token scopes (use the manifest below). Click **Install to Workspace**. Copy the **Bot User OAuth Token** (`xoxb-...`). 3) **OAuth & Permissions** → add bot token scopes (use the manifest below). Click **Install to Workspace**. Copy the **Bot User OAuth Token** (`xoxb-...`).
@@ -43,7 +45,7 @@ Use the manifest below so scopes and events stay in sync.
Multi-account support: use `channels.slack.accounts` with per-account tokens and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern. Multi-account support: use `channels.slack.accounts` with per-account tokens and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern.
## Clawdbot config (minimal) ### Clawdbot config (minimal)
Set tokens via env vars (recommended): Set tokens via env vars (recommended):
- `SLACK_APP_TOKEN=xapp-...` - `SLACK_APP_TOKEN=xapp-...`
@@ -63,7 +65,7 @@ Or via config:
} }
``` ```
## User token (optional) ### User token (optional)
Clawdbot can use a Slack user token (`xoxp-...`) for read operations (history, Clawdbot can use a Slack user token (`xoxp-...`) for read operations (history,
pins, reactions, emoji, member info). By default this stays read-only: reads pins, reactions, emoji, member info). By default this stays read-only: reads
prefer the user token when present, and writes still use the bot token unless prefer the user token when present, and writes still use the bot token unless
@@ -102,18 +104,51 @@ Example with userTokenReadOnly explicitly set (allow user token writes):
} }
``` ```
### Token usage #### Token usage
- Read operations (history, reactions list, pins list, emoji list, member info, - Read operations (history, reactions list, pins list, emoji list, member info,
search) prefer the user token when configured, otherwise the bot token. search) prefer the user token when configured, otherwise the bot token.
- Write operations (send/edit/delete messages, add/remove reactions, pin/unpin, - Write operations (send/edit/delete messages, add/remove reactions, pin/unpin,
file uploads) use the bot token by default. If `userTokenReadOnly: false` and file uploads) use the bot token by default. If `userTokenReadOnly: false` and
no bot token is available, Clawdbot falls back to the user token. no bot token is available, Clawdbot falls back to the user token.
## History context ### History context
- `channels.slack.historyLimit` (or `channels.slack.accounts.*.historyLimit`) controls how many recent channel/group messages are wrapped into the prompt. - `channels.slack.historyLimit` (or `channels.slack.accounts.*.historyLimit`) controls how many recent channel/group messages are wrapped into the prompt.
- Falls back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50). - Falls back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50).
## Manifest (optional) ## HTTP mode (Events API)
Use HTTP webhook mode when your Gateway is reachable by Slack over HTTPS (typical for server deployments).
HTTP mode uses the Events API + Interactivity + Slash Commands with a shared request URL.
### Setup
1) Create a Slack app and **disable Socket Mode** (optional if you only use HTTP).
2) **Basic Information** → copy the **Signing Secret**.
3) **OAuth & Permissions** → install the app and copy the **Bot User OAuth Token** (`xoxb-...`).
4) **Event Subscriptions** → enable events and set the **Request URL** to your gateway webhook path (default `/slack/events`).
5) **Interactivity & Shortcuts** → enable and set the same **Request URL**.
6) **Slash Commands** → set the same **Request URL** for your command(s).
Example request URL:
`https://gateway-host/slack/events`
### Clawdbot config (minimal)
```json5
{
channels: {
slack: {
enabled: true,
mode: "http",
botToken: "xoxb-...",
signingSecret: "your-signing-secret",
webhookPath: "/slack/events"
}
}
}
```
Multi-account HTTP mode: set `channels.slack.accounts.<id>.mode = "http"` and provide a unique
`webhookPath` per account so each Slack app can point to its own URL.
### Manifest (optional)
Use this Slack app manifest to create the app quickly (adjust the name/command if you want). Include the Use this Slack app manifest to create the app quickly (adjust the name/command if you want). Include the
user scopes if you plan to configure a user token. user scopes if you plan to configure a user token.

View File

@@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import { validateConfigObject } from "./config.js";
describe("Slack HTTP mode config", () => {
it("accepts HTTP mode when signing secret is configured", () => {
const res = validateConfigObject({
channels: {
slack: {
mode: "http",
signingSecret: "secret",
},
},
});
expect(res.ok).toBe(true);
});
it("rejects HTTP mode without signing secret", () => {
const res = validateConfigObject({
channels: {
slack: {
mode: "http",
},
},
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues[0]?.path).toBe("channels.slack.signingSecret");
}
});
it("accepts account HTTP mode when base signing secret is set", () => {
const res = validateConfigObject({
channels: {
slack: {
signingSecret: "secret",
accounts: {
ops: {
mode: "http",
},
},
},
},
});
expect(res.ok).toBe(true);
});
it("rejects account HTTP mode without signing secret", () => {
const res = validateConfigObject({
channels: {
slack: {
accounts: {
ops: {
mode: "http",
},
},
},
},
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues[0]?.path).toBe("channels.slack.accounts.ops.signingSecret");
}
});
});

View File

@@ -70,6 +70,12 @@ export type SlackThreadConfig = {
export type SlackAccountConfig = { export type SlackAccountConfig = {
/** Optional display name for this account (used in CLI/UI lists). */ /** Optional display name for this account (used in CLI/UI lists). */
name?: string; name?: string;
/** Slack connection mode (socket|http). Default: socket. */
mode?: "socket" | "http";
/** Slack signing secret (required for HTTP mode). */
signingSecret?: string;
/** Slack Events API webhook path (default: /slack/events). */
webhookPath?: string;
/** Optional provider capability tags used for agent/runtime guidance. */ /** Optional provider capability tags used for agent/runtime guidance. */
capabilities?: string[]; capabilities?: string[];
/** Override native command registration for Slack (bool or "auto"). */ /** Override native command registration for Slack (bool or "auto"). */

View File

@@ -258,6 +258,9 @@ export const SlackThreadSchema = z.object({
export const SlackAccountSchema = z.object({ export const SlackAccountSchema = z.object({
name: z.string().optional(), name: z.string().optional(),
mode: z.enum(["socket", "http"]).optional(),
signingSecret: z.string().optional(),
webhookPath: z.string().optional(),
capabilities: z.array(z.string()).optional(), capabilities: z.array(z.string()).optional(),
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
commands: ProviderCommandsSchema, commands: ProviderCommandsSchema,
@@ -305,7 +308,35 @@ export const SlackAccountSchema = z.object({
}); });
export const SlackConfigSchema = SlackAccountSchema.extend({ export const SlackConfigSchema = SlackAccountSchema.extend({
mode: z.enum(["socket", "http"]).optional().default("socket"),
signingSecret: z.string().optional(),
webhookPath: z.string().optional().default("/slack/events"),
accounts: z.record(z.string(), SlackAccountSchema.optional()).optional(), accounts: z.record(z.string(), SlackAccountSchema.optional()).optional(),
}).superRefine((value, ctx) => {
const baseMode = value.mode ?? "socket";
if (baseMode === "http" && !value.signingSecret) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'channels.slack.mode="http" requires channels.slack.signingSecret',
path: ["signingSecret"],
});
}
if (!value.accounts) return;
for (const [accountId, account] of Object.entries(value.accounts)) {
if (!account) continue;
if (account.enabled === false) continue;
const accountMode = account.mode ?? baseMode;
if (accountMode !== "http") continue;
const accountSecret = account.signingSecret ?? value.signingSecret;
if (!accountSecret) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
'channels.slack.accounts.*.mode="http" requires channels.slack.signingSecret or channels.slack.accounts.*.signingSecret',
path: ["accounts", accountId, "signingSecret"],
});
}
}
}); });
export const SignalAccountSchemaBase = z.object({ export const SignalAccountSchemaBase = z.object({

View File

@@ -8,6 +8,7 @@ import type { WebSocketServer } from "ws";
import { handleA2uiHttpRequest } from "../canvas-host/a2ui.js"; import { handleA2uiHttpRequest } from "../canvas-host/a2ui.js";
import type { CanvasHostHandler } from "../canvas-host/server.js"; import type { CanvasHostHandler } from "../canvas-host/server.js";
import type { createSubsystemLogger } from "../logging.js"; import type { createSubsystemLogger } from "../logging.js";
import { handleSlackHttpRequest } from "../slack/http/index.js";
import { handleControlUiHttpRequest } from "./control-ui.js"; import { handleControlUiHttpRequest } from "./control-ui.js";
import { import {
extractHookToken, extractHookToken,
@@ -208,6 +209,7 @@ export function createGatewayHttpServer(opts: {
void (async () => { void (async () => {
if (await handleHooksRequest(req, res)) return; if (await handleHooksRequest(req, res)) return;
if (await handleSlackHttpRequest(req, res)) return;
if (handlePluginRequest && (await handlePluginRequest(req, res))) return; if (handlePluginRequest && (await handlePluginRequest(req, res))) return;
if (openAiChatCompletionsEnabled) { if (openAiChatCompletionsEnabled) {
if (await handleOpenAiHttpRequest(req, res, { auth: resolvedAuth })) return; if (await handleOpenAiHttpRequest(req, res, { auth: resolvedAuth })) return;

View File

@@ -20,9 +20,7 @@ import type { DedupeEntry } from "./server-shared.js";
import type { PluginRegistry } from "../plugins/registry.js"; import type { PluginRegistry } from "../plugins/registry.js";
export async function createGatewayRuntimeState(params: { export async function createGatewayRuntimeState(params: {
cfg: { cfg: import("../config/config.js").ClawdbotConfig;
canvasHost?: { root?: string; enabled?: boolean; liveReload?: boolean };
};
bindHost: string; bindHost: string;
port: number; port: number;
controlUiEnabled: boolean; controlUiEnabled: boolean;

1
src/slack/http/index.ts Normal file
View File

@@ -0,0 +1 @@
export * from "./registry.js";

View File

@@ -0,0 +1,87 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
handleSlackHttpRequest,
normalizeSlackWebhookPath,
registerSlackHttpHandler,
} from "./registry.js";
describe("normalizeSlackWebhookPath", () => {
it("returns the default path when input is empty", () => {
expect(normalizeSlackWebhookPath()).toBe("/slack/events");
expect(normalizeSlackWebhookPath(" ")).toBe("/slack/events");
});
it("ensures a leading slash", () => {
expect(normalizeSlackWebhookPath("slack/events")).toBe("/slack/events");
expect(normalizeSlackWebhookPath("/hooks/slack")).toBe("/hooks/slack");
});
});
describe("registerSlackHttpHandler", () => {
const unregisters: Array<() => void> = [];
afterEach(() => {
for (const unregister of unregisters.splice(0)) unregister();
});
it("routes requests to a registered handler", async () => {
const handler = vi.fn();
unregisters.push(
registerSlackHttpHandler({
path: "/slack/events",
handler,
}),
);
const req = { url: "/slack/events?foo=bar" } as IncomingMessage;
const res = {} as ServerResponse;
const handled = await handleSlackHttpRequest(req, res);
expect(handled).toBe(true);
expect(handler).toHaveBeenCalledWith(req, res);
});
it("returns false when no handler matches", async () => {
const req = { url: "/slack/other" } as IncomingMessage;
const res = {} as ServerResponse;
const handled = await handleSlackHttpRequest(req, res);
expect(handled).toBe(false);
});
it("logs and ignores duplicate registrations", async () => {
const handler = vi.fn();
const log = vi.fn();
unregisters.push(
registerSlackHttpHandler({
path: "/slack/events",
handler,
log,
accountId: "primary",
}),
);
unregisters.push(
registerSlackHttpHandler({
path: "/slack/events",
handler: vi.fn(),
log,
accountId: "duplicate",
}),
);
const req = { url: "/slack/events" } as IncomingMessage;
const res = {} as ServerResponse;
const handled = await handleSlackHttpRequest(req, res);
expect(handled).toBe(true);
expect(handler).toHaveBeenCalledWith(req, res);
expect(log).toHaveBeenCalledWith(
'slack: webhook path /slack/events already registered for account "duplicate"',
);
});
});

View File

@@ -0,0 +1,45 @@
import type { IncomingMessage, ServerResponse } from "node:http";
export type SlackHttpRequestHandler = (
req: IncomingMessage,
res: ServerResponse,
) => Promise<void> | void;
type RegisterSlackHttpHandlerArgs = {
path?: string | null;
handler: SlackHttpRequestHandler;
log?: (message: string) => void;
accountId?: string;
};
const slackHttpRoutes = new Map<string, SlackHttpRequestHandler>();
export function normalizeSlackWebhookPath(path?: string | null): string {
const trimmed = path?.trim();
if (!trimmed) return "/slack/events";
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
}
export function registerSlackHttpHandler(params: RegisterSlackHttpHandlerArgs): () => void {
const normalizedPath = normalizeSlackWebhookPath(params.path);
if (slackHttpRoutes.has(normalizedPath)) {
const suffix = params.accountId ? ` for account "${params.accountId}"` : "";
params.log?.(`slack: webhook path ${normalizedPath} already registered${suffix}`);
return () => {};
}
slackHttpRoutes.set(normalizedPath, params.handler);
return () => {
slackHttpRoutes.delete(normalizedPath);
};
}
export async function handleSlackHttpRequest(
req: IncomingMessage,
res: ServerResponse,
): Promise<boolean> {
const url = new URL(req.url ?? "/", "http://localhost");
const handler = slackHttpRoutes.get(url.pathname);
if (!handler) return false;
await handler(req, res);
return true;
}

View File

@@ -1,4 +1,6 @@
import { App } from "@slack/bolt"; import type { IncomingMessage, ServerResponse } from "node:http";
import { App, HTTPReceiver } from "@slack/bolt";
import { resolveTextChunkLimit } from "../../auto-reply/chunk.js"; import { resolveTextChunkLimit } from "../../auto-reply/chunk.js";
import { DEFAULT_GROUP_HISTORY_LIMIT } from "../../auto-reply/reply/history.js"; import { DEFAULT_GROUP_HISTORY_LIMIT } from "../../auto-reply/reply/history.js";
@@ -14,6 +16,7 @@ import { resolveSlackAccount } from "../accounts.js";
import { resolveSlackChannelAllowlist } from "../resolve-channels.js"; import { resolveSlackChannelAllowlist } from "../resolve-channels.js";
import { resolveSlackUserAllowlist } from "../resolve-users.js"; import { resolveSlackUserAllowlist } from "../resolve-users.js";
import { resolveSlackAppToken, resolveSlackBotToken } from "../token.js"; import { resolveSlackAppToken, resolveSlackBotToken } from "../token.js";
import { normalizeSlackWebhookPath, registerSlackHttpHandler } from "../http/index.js";
import { resolveSlackSlashCommandConfig } from "./commands.js"; import { resolveSlackSlashCommandConfig } from "./commands.js";
import { createSlackMonitorContext } from "./context.js"; import { createSlackMonitorContext } from "./context.js";
import { registerSlackMonitorEvents } from "./events.js"; import { registerSlackMonitorEvents } from "./events.js";
@@ -49,11 +52,21 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
const sessionScope: SessionScope = sessionCfg?.scope ?? "per-sender"; const sessionScope: SessionScope = sessionCfg?.scope ?? "per-sender";
const mainKey = normalizeMainKey(sessionCfg?.mainKey); const mainKey = normalizeMainKey(sessionCfg?.mainKey);
const slackMode = opts.mode ?? account.config.mode ?? "socket";
const slackWebhookPath = normalizeSlackWebhookPath(account.config.webhookPath);
const signingSecret = account.config.signingSecret?.trim();
const botToken = resolveSlackBotToken(opts.botToken ?? account.botToken); const botToken = resolveSlackBotToken(opts.botToken ?? account.botToken);
const appToken = resolveSlackAppToken(opts.appToken ?? account.appToken); const appToken = resolveSlackAppToken(opts.appToken ?? account.appToken);
if (!botToken || !appToken) { if (!botToken || (slackMode !== "http" && !appToken)) {
const missing =
slackMode === "http"
? `Slack bot token missing for account "${account.accountId}" (set channels.slack.accounts.${account.accountId}.botToken or SLACK_BOT_TOKEN for default).`
: `Slack bot + app tokens missing for account "${account.accountId}" (set channels.slack.accounts.${account.accountId}.botToken/appToken or SLACK_BOT_TOKEN/SLACK_APP_TOKEN for default).`;
throw new Error(missing);
}
if (slackMode === "http" && !signingSecret) {
throw new Error( throw new Error(
`Slack bot + app tokens missing for account "${account.accountId}" (set channels.slack.accounts.${account.accountId}.botToken/appToken or SLACK_BOT_TOKEN/SLACK_APP_TOKEN for default).`, `Slack signing secret missing for account "${account.accountId}" (set channels.slack.signingSecret or channels.slack.accounts.${account.accountId}.signingSecret).`,
); );
} }
@@ -102,11 +115,32 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
const mediaMaxBytes = (opts.mediaMaxMb ?? slackCfg.mediaMaxMb ?? 20) * 1024 * 1024; const mediaMaxBytes = (opts.mediaMaxMb ?? slackCfg.mediaMaxMb ?? 20) * 1024 * 1024;
const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false; const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false;
const app = new App({ const receiver =
token: botToken, slackMode === "http"
appToken, ? new HTTPReceiver({
socketMode: true, signingSecret: signingSecret ?? "",
}); endpoints: slackWebhookPath,
})
: null;
const app = new App(
slackMode === "socket"
? {
token: botToken,
appToken,
socketMode: true,
}
: {
token: botToken,
receiver: receiver ?? undefined,
},
);
const slackHttpHandler =
slackMode === "http" && receiver
? async (req: IncomingMessage, res: ServerResponse) => {
await Promise.resolve(receiver.requestListener(req, res));
}
: null;
let unregisterHttpHandler: (() => void) | null = null;
let botUserId = ""; let botUserId = "";
let teamId = ""; let teamId = "";
@@ -164,6 +198,14 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
registerSlackMonitorEvents({ ctx, account, handleSlackMessage }); registerSlackMonitorEvents({ ctx, account, handleSlackMessage });
registerSlackMonitorSlashCommands({ ctx, account }); registerSlackMonitorSlashCommands({ ctx, account });
if (slackMode === "http" && slackHttpHandler) {
unregisterHttpHandler = registerSlackHttpHandler({
path: slackWebhookPath,
handler: slackHttpHandler,
log: runtime.log,
accountId: account.accountId,
});
}
if (resolveToken) { if (resolveToken) {
void (async () => { void (async () => {
@@ -284,13 +326,17 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
} }
const stopOnAbort = () => { const stopOnAbort = () => {
if (opts.abortSignal?.aborted) void app.stop(); if (opts.abortSignal?.aborted && slackMode === "socket") void app.stop();
}; };
opts.abortSignal?.addEventListener("abort", stopOnAbort, { once: true }); opts.abortSignal?.addEventListener("abort", stopOnAbort, { once: true });
try { try {
await app.start(); if (slackMode === "socket") {
runtime.log?.("slack socket mode connected"); await app.start();
runtime.log?.("slack socket mode connected");
} else {
runtime.log?.(`slack http mode listening at ${slackWebhookPath}`);
}
if (opts.abortSignal?.aborted) return; if (opts.abortSignal?.aborted) return;
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
opts.abortSignal?.addEventListener("abort", () => resolve(), { opts.abortSignal?.addEventListener("abort", () => resolve(), {
@@ -299,6 +345,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
}); });
} finally { } finally {
opts.abortSignal?.removeEventListener("abort", stopOnAbort); opts.abortSignal?.removeEventListener("abort", stopOnAbort);
unregisterHttpHandler?.();
await app.stop().catch(() => undefined); await app.stop().catch(() => undefined);
} }
} }