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:
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
65
src/config/slack-http-config.test.ts
Normal file
65
src/config/slack-http-config.test.ts
Normal 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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"). */
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
1
src/slack/http/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "./registry.js";
|
||||||
87
src/slack/http/registry.test.ts
Normal file
87
src/slack/http/registry.test.ts
Normal 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"',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
45
src/slack/http/registry.ts
Normal file
45
src/slack/http/registry.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user