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:
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 { 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<void>((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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user