diff --git a/CHANGELOG.md b/CHANGELOG.md index ad5a7d419..312ea5d99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. - 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. +- Slack: add HTTP webhook mode via Bolt HTTP receiver for Events API deployments. (#1143) — thanks @jdrhyne. ### Fixes - Auth profiles: keep auto-pinned preference while allowing rotation on failover; user pins stay locked. (#1138) — thanks @cheeeee. diff --git a/docs/channels/slack.md b/docs/channels/slack.md index 3adeff733..6607d54af 100644 --- a/docs/channels/slack.md +++ b/docs/channels/slack.md @@ -1,11 +1,13 @@ --- -summary: "Slack socket mode setup and Clawdbot config" -read_when: "Setting up Slack or debugging Slack socket mode" +summary: "Slack setup for socket or HTTP webhook 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**. 2) Create an **App Token** (`xapp-...`) and **Bot Token** (`xoxb-...`). 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. 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-...`). @@ -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. -## Clawdbot config (minimal) +### Clawdbot config (minimal) Set tokens via env vars (recommended): - `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, 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 @@ -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, search) prefer the user token when configured, otherwise the bot token. - Write operations (send/edit/delete messages, add/remove reactions, pin/unpin, file uploads) use the bot token by default. If `userTokenReadOnly: false` and 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. - 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..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 user scopes if you plan to configure a user token. diff --git a/src/config/slack-http-config.test.ts b/src/config/slack-http-config.test.ts new file mode 100644 index 000000000..ec30479a2 --- /dev/null +++ b/src/config/slack-http-config.test.ts @@ -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"); + } + }); +}); diff --git a/src/config/types.slack.ts b/src/config/types.slack.ts index 1609850c1..956b4c029 100644 --- a/src/config/types.slack.ts +++ b/src/config/types.slack.ts @@ -70,6 +70,12 @@ export type SlackThreadConfig = { export type SlackAccountConfig = { /** Optional display name for this account (used in CLI/UI lists). */ 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. */ capabilities?: string[]; /** Override native command registration for Slack (bool or "auto"). */ diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index fa5955aa1..17027bec0 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -258,6 +258,9 @@ export const SlackThreadSchema = z.object({ export const SlackAccountSchema = z.object({ name: z.string().optional(), + mode: z.enum(["socket", "http"]).optional(), + signingSecret: z.string().optional(), + webhookPath: z.string().optional(), capabilities: z.array(z.string()).optional(), enabled: z.boolean().optional(), commands: ProviderCommandsSchema, @@ -305,7 +308,35 @@ export const SlackAccountSchema = z.object({ }); 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(), +}).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({ diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index f814d1659..c345f6299 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -8,6 +8,7 @@ import type { WebSocketServer } from "ws"; import { handleA2uiHttpRequest } from "../canvas-host/a2ui.js"; import type { CanvasHostHandler } from "../canvas-host/server.js"; import type { createSubsystemLogger } from "../logging.js"; +import { handleSlackHttpRequest } from "../slack/http/index.js"; import { handleControlUiHttpRequest } from "./control-ui.js"; import { extractHookToken, @@ -208,6 +209,7 @@ export function createGatewayHttpServer(opts: { void (async () => { if (await handleHooksRequest(req, res)) return; + if (await handleSlackHttpRequest(req, res)) return; if (handlePluginRequest && (await handlePluginRequest(req, res))) return; if (openAiChatCompletionsEnabled) { if (await handleOpenAiHttpRequest(req, res, { auth: resolvedAuth })) return; diff --git a/src/gateway/server-runtime-state.ts b/src/gateway/server-runtime-state.ts index 45501ead5..1d4f86dd5 100644 --- a/src/gateway/server-runtime-state.ts +++ b/src/gateway/server-runtime-state.ts @@ -20,9 +20,7 @@ import type { DedupeEntry } from "./server-shared.js"; import type { PluginRegistry } from "../plugins/registry.js"; export async function createGatewayRuntimeState(params: { - cfg: { - canvasHost?: { root?: string; enabled?: boolean; liveReload?: boolean }; - }; + cfg: import("../config/config.js").ClawdbotConfig; bindHost: string; port: number; controlUiEnabled: boolean; diff --git a/src/slack/http/index.ts b/src/slack/http/index.ts new file mode 100644 index 000000000..0e8ed1bc9 --- /dev/null +++ b/src/slack/http/index.ts @@ -0,0 +1 @@ +export * from "./registry.js"; diff --git a/src/slack/http/registry.test.ts b/src/slack/http/registry.test.ts new file mode 100644 index 000000000..9deee5b74 --- /dev/null +++ b/src/slack/http/registry.test.ts @@ -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"', + ); + }); +}); diff --git a/src/slack/http/registry.ts b/src/slack/http/registry.ts new file mode 100644 index 000000000..630c52fec --- /dev/null +++ b/src/slack/http/registry.ts @@ -0,0 +1,45 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; + +export type SlackHttpRequestHandler = ( + req: IncomingMessage, + res: ServerResponse, +) => Promise | void; + +type RegisterSlackHttpHandlerArgs = { + path?: string | null; + handler: SlackHttpRequestHandler; + log?: (message: string) => void; + accountId?: string; +}; + +const slackHttpRoutes = new Map(); + +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 { + const url = new URL(req.url ?? "/", "http://localhost"); + const handler = slackHttpRoutes.get(url.pathname); + if (!handler) return false; + await handler(req, res); + return true; +} diff --git a/src/slack/monitor/provider.ts b/src/slack/monitor/provider.ts index a1de12471..e416cf5ea 100644 --- a/src/slack/monitor/provider.ts +++ b/src/slack/monitor/provider.ts @@ -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 { 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 { resolveSlackUserAllowlist } from "../resolve-users.js"; import { resolveSlackAppToken, resolveSlackBotToken } from "../token.js"; +import { normalizeSlackWebhookPath, registerSlackHttpHandler } from "../http/index.js"; import { resolveSlackSlashCommandConfig } from "./commands.js"; import { createSlackMonitorContext } from "./context.js"; import { registerSlackMonitorEvents } from "./events.js"; @@ -49,11 +52,21 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { const sessionScope: SessionScope = sessionCfg?.scope ?? "per-sender"; 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 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( - `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 removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false; - const app = new App({ - token: botToken, - appToken, - socketMode: true, - }); + const receiver = + slackMode === "http" + ? new HTTPReceiver({ + 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 teamId = ""; @@ -164,6 +198,14 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { registerSlackMonitorEvents({ ctx, account, handleSlackMessage }); registerSlackMonitorSlashCommands({ ctx, account }); + if (slackMode === "http" && slackHttpHandler) { + unregisterHttpHandler = registerSlackHttpHandler({ + path: slackWebhookPath, + handler: slackHttpHandler, + log: runtime.log, + accountId: account.accountId, + }); + } if (resolveToken) { void (async () => { @@ -284,13 +326,17 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { } 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 }); try { - await app.start(); - runtime.log?.("slack socket mode connected"); + if (slackMode === "socket") { + await app.start(); + runtime.log?.("slack socket mode connected"); + } else { + runtime.log?.(`slack http mode listening at ${slackWebhookPath}`); + } if (opts.abortSignal?.aborted) return; await new Promise((resolve) => { opts.abortSignal?.addEventListener("abort", () => resolve(), { @@ -299,6 +345,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { }); } finally { opts.abortSignal?.removeEventListener("abort", stopOnAbort); + unregisterHttpHandler?.(); await app.stop().catch(() => undefined); } }