diff --git a/packages/plugins/official/slack-connector/__tests__/index.test.ts b/packages/plugins/official/slack-connector/__tests__/index.test.ts
new file mode 100644
index 0000000..fee7670
--- /dev/null
+++ b/packages/plugins/official/slack-connector/__tests__/index.test.ts
@@ -0,0 +1,982 @@
+///
+/**
+ * Slack Connector — Unit Tests
+ *
+ * Covers: DB key helpers, verifySlackSignature (replay, tampered, missing),
+ * slackApiCall, postSlackMessage, askAI, stripMention, plugin manifest/settings,
+ * app:init (4 endpoints), POST /connect, GET /channels, POST /webhook
+ * (url_verification, dedup, bot messages, mention, DM, async reply),
+ * POST /slash-command (usage, async reply, response_url).
+ */
+import plugin, {
+ buildChannelKey,
+ buildMessageKey,
+ buildConnectionKey,
+ verifySlackSignature,
+ slackApiCall,
+ postSlackMessage,
+ askAI,
+ stripMention,
+ SLACK_API_BASE,
+ AI_COMPLETIONS_PATH,
+ DEFAULT_SLASH_COMMAND,
+ DEFAULT_AI_MODEL,
+ MAX_REQUEST_AGE_MS,
+ SlackChannel,
+ SlackEventPayload,
+ SlackSlashCommandPayload,
+ ChannelConfig,
+ DedupRecord,
+} from "../src/index";
+import {
+ PluginContext,
+ PluginAPI,
+ PluginDatabaseAPI,
+ PluginEventBus,
+ EndpointDefinition,
+ EndpointRequest,
+ EndpointResponse,
+} from "@agentbase/plugin-sdk";
+import { createHmac } from "crypto";
+
+// ── Mock factory ──────────────────────────────────────────────────────────────
+
+function createMockAPI(): PluginAPI & { _endpoints: EndpointDefinition[] } {
+ const store = new Map();
+ const _endpoints: EndpointDefinition[] = [];
+
+ const db: PluginDatabaseAPI = {
+ set: jest
+ .fn()
+ .mockImplementation(async (k: string, v: unknown) => store.set(k, v)),
+ get: jest
+ .fn()
+ .mockImplementation(async (k: string) => store.get(k) ?? null),
+ delete: jest.fn().mockImplementation(async (k: string) => {
+ const had = store.has(k);
+ store.delete(k);
+ return had;
+ }),
+ keys: jest
+ .fn()
+ .mockImplementation(async (prefix?: string) =>
+ [...store.keys()].filter((k) => !prefix || k.startsWith(prefix)),
+ ),
+ find: jest.fn().mockResolvedValue([]),
+ count: jest.fn().mockResolvedValue(0),
+ };
+
+ const events: PluginEventBus = {
+ emit: jest.fn().mockResolvedValue(undefined),
+ on: jest.fn(),
+ off: jest.fn(),
+ };
+
+ const configStore = new Map();
+
+ return {
+ _endpoints,
+ getConfig: jest
+ .fn()
+ .mockImplementation((key: string) => configStore.get(key) ?? undefined),
+ setConfig: jest
+ .fn()
+ .mockImplementation(async (k: string, v: unknown) =>
+ configStore.set(k, v),
+ ),
+ makeRequest: jest.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: jest.fn().mockResolvedValue({}),
+ text: jest.fn().mockResolvedValue(""),
+ }),
+ log: jest.fn(),
+ db,
+ events,
+ registerEndpoint: jest
+ .fn()
+ .mockImplementation((def: EndpointDefinition) => _endpoints.push(def)),
+ registerCronJob: jest.fn(),
+ registerWebhook: jest.fn(),
+ registerAdminPage: jest.fn(),
+ } as unknown as PluginAPI & { _endpoints: EndpointDefinition[] };
+}
+
+type MockAPI = ReturnType;
+type MockCtx = PluginContext & { api: MockAPI };
+
+function makeCtx(overrides: Partial = {}): MockCtx {
+ const api = createMockAPI();
+ return {
+ appId: "app-1",
+ userId: "user-1",
+ config: {},
+ api,
+ ...overrides,
+ } as MockCtx;
+}
+
+interface MockRes {
+ status: jest.Mock;
+ json: jest.Mock;
+ send: jest.Mock;
+ _status: number;
+ _body: unknown;
+}
+
+function makeRes(): MockRes {
+ const r: MockRes = {
+ _status: 200,
+ _body: undefined,
+ status: jest.fn(),
+ json: jest.fn(),
+ send: jest.fn(),
+ };
+ r.status.mockImplementation((code: number) => {
+ r._status = code;
+ return r;
+ });
+ r.json.mockImplementation((body: unknown) => {
+ r._body = body;
+ });
+ return r;
+}
+
+function makeReq(overrides: Partial = {}): EndpointRequest {
+ return {
+ method: "GET",
+ path: "/",
+ params: {},
+ query: {},
+ body: {},
+ headers: {},
+ ...overrides,
+ };
+}
+
+async function runInit(ctx: MockCtx): Promise {
+ const hook = plugin.definition.hooks?.["app:init"];
+ if (!hook) throw new Error("app:init hook not registered");
+ await hook(ctx);
+}
+
+function getEndpoint(
+ api: MockAPI,
+ method: string,
+ path: string,
+): EndpointDefinition {
+ const ep = api._endpoints.find((e) => e.method === method && e.path === path);
+ if (!ep) throw new Error(`Endpoint ${method} ${path} not found`);
+ return ep;
+}
+
+/** Build a valid Slack HMAC-SHA256 signature header value. */
+function buildSlackSig(
+ secret: string,
+ timestamp: string,
+ body: string,
+): string {
+ const sigBase = `v0:${timestamp}:${body}`;
+ const hmac = createHmac("sha256", secret);
+ hmac.update(sigBase, "utf8");
+ return `v0=${hmac.digest("hex")}`;
+}
+
+// ── DB Key Helpers ────────────────────────────────────────────────────────────
+
+describe("DB key helpers", () => {
+ it("buildChannelKey", () => {
+ expect(buildChannelKey("C123")).toBe("channel:C123");
+ });
+
+ it("buildMessageKey", () => {
+ expect(buildMessageKey("Ev123")).toBe("message:Ev123");
+ });
+
+ it("buildConnectionKey", () => {
+ expect(buildConnectionKey()).toBe("connection:config");
+ });
+});
+
+// ── verifySlackSignature ──────────────────────────────────────────────────────
+
+describe("verifySlackSignature", () => {
+ const SECRET = "slack_test_secret";
+ const BODY = '{"type":"event_callback"}';
+
+ function makeTimestamp(offsetMs = 0): string {
+ return String(Math.floor((Date.now() + offsetMs) / 1000));
+ }
+
+ it("returns true for a valid signature", () => {
+ const ts = makeTimestamp();
+ const sig = buildSlackSig(SECRET, ts, BODY);
+ expect(verifySlackSignature(SECRET, BODY, ts, sig)).toBe(true);
+ });
+
+ it("returns false when signature is missing", () => {
+ const ts = makeTimestamp();
+ expect(verifySlackSignature(SECRET, BODY, ts, undefined)).toBe(false);
+ });
+
+ it("returns false when timestamp is missing", () => {
+ const ts = makeTimestamp();
+ const sig = buildSlackSig(SECRET, ts, BODY);
+ expect(verifySlackSignature(SECRET, BODY, undefined, sig)).toBe(false);
+ });
+
+ it("returns false when signature does not start with v0=", () => {
+ const ts = makeTimestamp();
+ expect(verifySlackSignature(SECRET, BODY, ts, "v1=abc123")).toBe(false);
+ });
+
+ it("returns false for a replayed request older than 5 minutes", () => {
+ const oldTs = makeTimestamp(-(MAX_REQUEST_AGE_MS + 1000));
+ const sig = buildSlackSig(SECRET, oldTs, BODY);
+ expect(verifySlackSignature(SECRET, BODY, oldTs, sig)).toBe(false);
+ });
+
+ it("returns false for a request with a future timestamp > 5 minutes ahead", () => {
+ const futureTs = makeTimestamp(MAX_REQUEST_AGE_MS + 1000);
+ const sig = buildSlackSig(SECRET, futureTs, BODY);
+ expect(verifySlackSignature(SECRET, BODY, futureTs, sig)).toBe(false);
+ });
+
+ it("returns false when body has been tampered with", () => {
+ const ts = makeTimestamp();
+ const sig = buildSlackSig(SECRET, ts, BODY);
+ expect(verifySlackSignature(SECRET, '{"type":"tampered"}', ts, sig)).toBe(
+ false,
+ );
+ });
+
+ it("returns false when secret is wrong", () => {
+ const ts = makeTimestamp();
+ const sig = buildSlackSig("wrong_secret", ts, BODY);
+ expect(verifySlackSignature(SECRET, BODY, ts, sig)).toBe(false);
+ });
+
+ it("accepts injectable nowMs for time-locked tests", () => {
+ const tsSeconds = 1_700_000_000;
+ const body = "test";
+ const sig = buildSlackSig(SECRET, String(tsSeconds), body);
+ const nowMs = tsSeconds * 1000; // exactly on time
+ expect(
+ verifySlackSignature(SECRET, body, String(tsSeconds), sig, nowMs),
+ ).toBe(true);
+ });
+});
+
+// ── slackApiCall ──────────────────────────────────────────────────────────────
+
+describe("slackApiCall", () => {
+ it("calls the correct Slack API URL with Bearer auth", async () => {
+ const mockMake = jest.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: jest.fn().mockResolvedValue({ ok: true, channels: [] }),
+ });
+ const result = await slackApiCall(
+ mockMake,
+ "xoxb-token",
+ "conversations.list",
+ { limit: 10 },
+ );
+ expect(result.ok).toBe(true);
+ const calledUrl = (mockMake.mock.calls[0] as [string, RequestInit])[0];
+ expect(calledUrl).toContain(`${SLACK_API_BASE}/conversations.list`);
+ expect(calledUrl).toContain("limit=10");
+ const calledOpts = (mockMake.mock.calls[0] as [string, RequestInit])[1];
+ expect(
+ (calledOpts?.headers as Record)?.["Authorization"],
+ ).toBe("Bearer xoxb-token");
+ });
+
+ it("throws on HTTP error", async () => {
+ const mockMake = jest.fn().mockResolvedValue({ ok: false, status: 503 });
+ await expect(slackApiCall(mockMake, "token", "auth.test")).rejects.toThrow(
+ "HTTP error: 503",
+ );
+ });
+
+ it("throws when Slack returns ok:false", async () => {
+ const mockMake = jest.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: jest
+ .fn()
+ .mockResolvedValue({ ok: false, error: "channel_not_found" }),
+ });
+ await expect(
+ slackApiCall(mockMake, "token", "conversations.info"),
+ ).rejects.toThrow("channel_not_found");
+ });
+});
+
+// ── postSlackMessage ──────────────────────────────────────────────────────────
+
+describe("postSlackMessage", () => {
+ it("calls chat.postMessage with correct body", async () => {
+ let capturedBody: string | undefined;
+ const mockMake = jest
+ .fn()
+ .mockImplementation(async (_url: string, opts?: RequestInit) => {
+ capturedBody = opts?.body as string;
+ return {
+ ok: true,
+ status: 200,
+ json: jest.fn().mockResolvedValue({ ok: true }),
+ };
+ });
+ await postSlackMessage(mockMake, "xoxb-token", "C123", "Hello!");
+ const body = JSON.parse(capturedBody ?? "{}") as Record;
+ expect(body["channel"]).toBe("C123");
+ expect(body["text"]).toBe("Hello!");
+ });
+
+ it("throws on HTTP error", async () => {
+ const mockMake = jest.fn().mockResolvedValue({ ok: false, status: 500 });
+ await expect(
+ postSlackMessage(mockMake, "token", "C1", "Hello"),
+ ).rejects.toThrow("HTTP error");
+ });
+
+ it("throws when Slack returns ok:false", async () => {
+ const mockMake = jest.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: jest.fn().mockResolvedValue({ ok: false, error: "not_in_channel" }),
+ });
+ await expect(
+ postSlackMessage(mockMake, "token", "C1", "Hi"),
+ ).rejects.toThrow("not_in_channel");
+ });
+});
+
+// ── askAI ─────────────────────────────────────────────────────────────────────
+
+describe("askAI", () => {
+ it("returns content from OpenAI-style response", async () => {
+ const mockMake = jest.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: jest.fn().mockResolvedValue({
+ choices: [{ message: { content: "AI says hi" } }],
+ }),
+ });
+ const result = await askAI(mockMake, "Hello");
+ expect(result).toBe("AI says hi");
+ expect(mockMake).toHaveBeenCalledWith(
+ AI_COMPLETIONS_PATH,
+ expect.objectContaining({ method: "POST" }),
+ );
+ });
+
+ it("returns content from native agentbase response shape", async () => {
+ const mockMake = jest.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: jest.fn().mockResolvedValue({ content: "Native reply" }),
+ });
+ expect(await askAI(mockMake, "q")).toBe("Native reply");
+ });
+
+ it("throws on non-ok response", async () => {
+ const mockMake = jest.fn().mockResolvedValue({ ok: false, status: 503 });
+ await expect(askAI(mockMake, "q")).rejects.toThrow("AI service error: 503");
+ });
+
+ it("throws on unexpected response shape", async () => {
+ const mockMake = jest.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: jest.fn().mockResolvedValue({ weird: true }),
+ });
+ await expect(askAI(mockMake, "q")).rejects.toThrow(
+ "Unexpected AI response shape",
+ );
+ });
+
+ it("passes the model parameter", async () => {
+ let capturedBody: string | undefined;
+ const mockMake = jest
+ .fn()
+ .mockImplementation(async (_u: string, opts?: RequestInit) => {
+ capturedBody = opts?.body as string;
+ return {
+ ok: true,
+ status: 200,
+ json: jest
+ .fn()
+ .mockResolvedValue({ choices: [{ message: { content: "ok" } }] }),
+ };
+ });
+ await askAI(mockMake, "q", "claude-3-5-sonnet");
+ const body = JSON.parse(capturedBody ?? "{}") as Record;
+ expect(body["model"]).toBe("claude-3-5-sonnet");
+ });
+});
+
+// ── stripMention ──────────────────────────────────────────────────────────────
+
+describe("stripMention", () => {
+ it("removes leading <@UXXXXXXX> mention", () => {
+ expect(stripMention("<@U12345678> What is 2+2?")).toBe("What is 2+2?");
+ });
+
+ it("removes leading bot mention with extra spaces", () => {
+ expect(stripMention("<@UABC> Hello bot")).toBe("Hello bot");
+ });
+
+ it("leaves text unchanged when no mention", () => {
+ expect(stripMention("Hello bot")).toBe("Hello bot");
+ });
+
+ it("returns empty string when only mention", () => {
+ expect(stripMention("<@U123>")).toBe("");
+ });
+
+ it("is case-insensitive for mention characters", () => {
+ expect(stripMention("<@uabc123> lower case")).toBe("lower case");
+ });
+});
+
+// ── Plugin manifest / settings ────────────────────────────────────────────────
+
+describe("plugin manifest / settings", () => {
+ it("name is slack-connector", () => {
+ expect(plugin.definition.name).toBe("slack-connector");
+ });
+
+ it("version is 1.0.0", () => {
+ expect(plugin.definition.version).toBe("1.0.0");
+ });
+
+ it("has required settings", () => {
+ const settings = plugin.definition.settings!;
+ expect(settings).toHaveProperty("slackBotToken");
+ expect(settings).toHaveProperty("slackSigningSecret");
+ expect(settings).toHaveProperty("defaultChannel");
+ expect(settings).toHaveProperty("listenForMentions");
+ expect(settings).toHaveProperty("slashCommand");
+ expect(settings).toHaveProperty("aiModel");
+ });
+
+ it("slackBotToken has encrypted:true", () => {
+ expect(plugin.definition.settings!["slackBotToken"]?.["encrypted"]).toBe(
+ true,
+ );
+ });
+
+ it("slackSigningSecret has encrypted:true", () => {
+ expect(
+ plugin.definition.settings!["slackSigningSecret"]?.["encrypted"],
+ ).toBe(true);
+ });
+
+ it("slashCommand default is /ai", () => {
+ expect(plugin.definition.settings!["slashCommand"]?.default).toBe(
+ DEFAULT_SLASH_COMMAND,
+ );
+ });
+});
+
+// ── app:init ──────────────────────────────────────────────────────────────────
+
+describe("app:init", () => {
+ it("registers exactly 4 endpoints", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ expect(ctx.api._endpoints).toHaveLength(4);
+ });
+
+ it("registers POST /connect, GET /channels, POST /webhook, POST /slash-command", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ const paths = ctx.api._endpoints.map((e) => `${e.method} ${e.path}`);
+ expect(paths).toContain("POST /connect");
+ expect(paths).toContain("GET /channels");
+ expect(paths).toContain("POST /webhook");
+ expect(paths).toContain("POST /slash-command");
+ });
+});
+
+// ── POST /connect ─────────────────────────────────────────────────────────────
+
+describe("POST /connect", () => {
+ it("returns 400 when botToken is missing", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ const ep = getEndpoint(ctx.api, "POST", "/connect");
+ const res = makeRes();
+ await ep.handler!(
+ makeReq({ body: { signingSecret: "s" } }),
+ res as unknown as EndpointResponse,
+ );
+ expect(res._status).toBe(400);
+ expect(res._body).toMatchObject({
+ error: expect.stringContaining("botToken"),
+ });
+ });
+
+ it("returns 400 when signingSecret is missing", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ const ep = getEndpoint(ctx.api, "POST", "/connect");
+ const res = makeRes();
+ await ep.handler!(
+ makeReq({ body: { botToken: "xoxb-1" } }),
+ res as unknown as EndpointResponse,
+ );
+ expect(res._status).toBe(400);
+ });
+
+ it("returns 400 when auth.test call fails (invalid token)", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ (ctx.api.makeRequest as jest.Mock).mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: jest.fn().mockResolvedValue({ ok: false, error: "invalid_auth" }),
+ });
+ const ep = getEndpoint(ctx.api, "POST", "/connect");
+ const res = makeRes();
+ await ep.handler!(
+ makeReq({ body: { botToken: "xoxb-bad", signingSecret: "s" } }),
+ res as unknown as EndpointResponse,
+ );
+ expect(res._status).toBe(400);
+ expect((res._body as { error: string }).error).toContain("invalid_auth");
+ });
+
+ it("saves config and returns connected:true on success", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ (ctx.api.makeRequest as jest.Mock).mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: jest.fn().mockResolvedValue({ ok: true, user_id: "UBOT" }),
+ });
+ const ep = getEndpoint(ctx.api, "POST", "/connect");
+ const res = makeRes();
+ await ep.handler!(
+ makeReq({
+ body: { botToken: "xoxb-valid", signingSecret: "signing_s" },
+ }),
+ res as unknown as EndpointResponse,
+ );
+ expect(res._status).toBe(200);
+ expect((res._body as { connected: boolean }).connected).toBe(true);
+ expect(ctx.api.db.set).toHaveBeenCalledWith(
+ buildConnectionKey(),
+ expect.objectContaining({ botToken: "xoxb-valid" }),
+ );
+ });
+});
+
+// ── GET /channels ─────────────────────────────────────────────────────────────
+
+describe("GET /channels", () => {
+ it("returns 400 when bot token not configured", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ const ep = getEndpoint(ctx.api, "GET", "/channels");
+ const res = makeRes();
+ await ep.handler!(makeReq(), res as unknown as EndpointResponse);
+ expect(res._status).toBe(400);
+ });
+
+ it("returns channel list on success", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ (ctx.api.getConfig as jest.Mock).mockImplementation((k: string) =>
+ k === "slackBotToken" ? "xoxb-token" : undefined,
+ );
+ const channels: Partial[] = [
+ { id: "C1", name: "general", is_private: false, is_archived: false },
+ ];
+ (ctx.api.makeRequest as jest.Mock).mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: jest.fn().mockResolvedValue({ ok: true, channels }),
+ });
+ const ep = getEndpoint(ctx.api, "GET", "/channels");
+ const res = makeRes();
+ await ep.handler!(makeReq(), res as unknown as EndpointResponse);
+ expect(res._status).toBe(200);
+ expect((res._body as { channels: unknown[] }).channels).toHaveLength(1);
+ });
+
+ it("returns 502 when Slack API call fails", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ (ctx.api.getConfig as jest.Mock).mockReturnValue("xoxb-token");
+ (ctx.api.makeRequest as jest.Mock).mockResolvedValue({
+ ok: false,
+ status: 500,
+ });
+ const ep = getEndpoint(ctx.api, "GET", "/channels");
+ const res = makeRes();
+ await ep.handler!(makeReq(), res as unknown as EndpointResponse);
+ expect(res._status).toBe(502);
+ });
+});
+
+// ── POST /webhook ─────────────────────────────────────────────────────────────
+
+describe("POST /webhook", () => {
+ const SECRET = "test_signing_secret";
+
+ function makeWebhookReq(
+ body: SlackEventPayload | string,
+ sigOverride?: { ts?: string; sig?: string },
+ ): EndpointRequest {
+ const rawBody = typeof body === "string" ? body : JSON.stringify(body);
+ const ts = sigOverride?.ts ?? String(Math.floor(Date.now() / 1000));
+ const sig = sigOverride?.sig ?? buildSlackSig(SECRET, ts, rawBody);
+ return makeReq({
+ body,
+ headers: {
+ "x-slack-request-timestamp": ts,
+ "x-slack-signature": sig,
+ },
+ });
+ }
+
+ async function setupCtx(signingSecret = SECRET): Promise {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ (ctx.api.getConfig as jest.Mock).mockImplementation((k: string) => {
+ const cfg: Record = {
+ slackSigningSecret: signingSecret,
+ slackBotToken: "xoxb-token",
+ listenForMentions: true,
+ aiModel: DEFAULT_AI_MODEL,
+ };
+ return cfg[k];
+ });
+ return ctx;
+ }
+
+ it("returns 401 when signature is invalid", async () => {
+ const ctx = await setupCtx();
+ const ep = getEndpoint(ctx.api, "POST", "/webhook");
+ const res = makeRes();
+ const body: SlackEventPayload = { type: "event_callback" };
+ await ep.handler!(
+ makeWebhookReq(body, { sig: "v0=badhex" }),
+ res as unknown as EndpointResponse,
+ );
+ expect(res._status).toBe(401);
+ });
+
+ it("responds to url_verification challenge", async () => {
+ const ctx = await setupCtx();
+ const ep = getEndpoint(ctx.api, "POST", "/webhook");
+ const res = makeRes();
+ const body: SlackEventPayload = {
+ type: "url_verification",
+ challenge: "challenge_token_abc",
+ };
+ await ep.handler!(makeWebhookReq(body), res as unknown as EndpointResponse);
+ expect(res._status).toBe(200);
+ expect((res._body as { challenge: string }).challenge).toBe(
+ "challenge_token_abc",
+ );
+ });
+
+ it("deduplicates events with same event_id", async () => {
+ const ctx = await setupCtx();
+ await ctx.api.db.set(buildMessageKey("Ev_dup_123"), {
+ eventId: "Ev_dup_123",
+ });
+ const ep = getEndpoint(ctx.api, "POST", "/webhook");
+ const res = makeRes();
+ const body: SlackEventPayload = {
+ type: "event_callback",
+ event_id: "Ev_dup_123",
+ };
+ await ep.handler!(makeWebhookReq(body), res as unknown as EndpointResponse);
+ expect(res._status).toBe(200);
+ expect((res._body as { deduplicated: boolean }).deduplicated).toBe(true);
+ });
+
+ it("ignores bot messages (bot_id present)", async () => {
+ const ctx = await setupCtx();
+ const ep = getEndpoint(ctx.api, "POST", "/webhook");
+ const res = makeRes();
+ const body: SlackEventPayload = {
+ type: "event_callback",
+ event_id: "Ev_bot",
+ event: { type: "message", bot_id: "B123", text: "bot said hi" },
+ };
+ await ep.handler!(makeWebhookReq(body), res as unknown as EndpointResponse);
+ expect(res._status).toBe(200);
+ expect(ctx.api.makeRequest).not.toHaveBeenCalledWith(
+ AI_COMPLETIONS_PATH,
+ expect.anything(),
+ );
+ });
+
+ it("handles app_mention: stores dedup, fires async AI + post (no await needed)", async () => {
+ const ctx = await setupCtx();
+ (ctx.api.makeRequest as jest.Mock).mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: jest.fn().mockResolvedValue({
+ choices: [{ message: { content: "AI reply" } }],
+ ok: true,
+ }),
+ });
+
+ const ep = getEndpoint(ctx.api, "POST", "/webhook");
+ const res = makeRes();
+ const body: SlackEventPayload = {
+ type: "event_callback",
+ event_id: "Ev_mention_1",
+ event: {
+ type: "app_mention",
+ text: "<@UBOT> what is AI?",
+ channel: "C_GENERAL",
+ user: "U_HUMAN",
+ },
+ };
+ await ep.handler!(makeWebhookReq(body), res as unknown as EndpointResponse);
+
+ // Immediate acknowledgment
+ expect(res._status).toBe(200);
+ expect((res._body as { ok: boolean }).ok).toBe(true);
+
+ // Dedup key stored
+ expect(ctx.api.db.set).toHaveBeenCalledWith(
+ buildMessageKey("Ev_mention_1"),
+ expect.objectContaining({ eventId: "Ev_mention_1" }),
+ );
+ });
+
+ it("ignores non-mention messages when listenForMentions=true and channel is not DM", async () => {
+ const ctx = await setupCtx();
+ const ep = getEndpoint(ctx.api, "POST", "/webhook");
+ const res = makeRes();
+ const body: SlackEventPayload = {
+ type: "event_callback",
+ event_id: "Ev_regular",
+ event: {
+ type: "message",
+ text: "just a regular message",
+ channel: "C_PUBLIC",
+ user: "U_HUMAN",
+ },
+ };
+ await ep.handler!(makeWebhookReq(body), res as unknown as EndpointResponse);
+ expect(res._status).toBe(200);
+ });
+
+ it("skips signature check when signingSecret is not configured", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ // No signingSecret configured — should pass through
+ (ctx.api.getConfig as jest.Mock).mockImplementation((k: string) => {
+ if (k === "slackSigningSecret") return "";
+ if (k === "slackBotToken") return "xoxb-token";
+ if (k === "listenForMentions") return true;
+ return undefined;
+ });
+ const ep = getEndpoint(ctx.api, "POST", "/webhook");
+ const res = makeRes();
+ const body: SlackEventPayload = {
+ type: "url_verification",
+ challenge: "abc",
+ };
+ // No valid signature headers — should still work because secret is empty
+ await ep.handler!(
+ makeReq({ body, headers: {} }),
+ res as unknown as EndpointResponse,
+ );
+ expect(res._status).toBe(200);
+ expect((res._body as { challenge: string }).challenge).toBe("abc");
+ });
+});
+
+// ── POST /slash-command ───────────────────────────────────────────────────────
+
+describe("POST /slash-command", () => {
+ const SECRET = "slash_signing_secret";
+
+ function makeSlashReq(
+ body: SlackSlashCommandPayload,
+ useValidSig = true,
+ ): EndpointRequest {
+ const rawBody = JSON.stringify(body);
+ const ts = String(Math.floor(Date.now() / 1000));
+ const sig = useValidSig ? buildSlackSig(SECRET, ts, rawBody) : "v0=invalid";
+ return makeReq({
+ body,
+ headers: { "x-slack-request-timestamp": ts, "x-slack-signature": sig },
+ });
+ }
+
+ async function setupCtx(): Promise {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ (ctx.api.getConfig as jest.Mock).mockImplementation((k: string) => {
+ const cfg: Record = {
+ slackSigningSecret: SECRET,
+ slackBotToken: "xoxb-token",
+ slashCommand: DEFAULT_SLASH_COMMAND,
+ aiModel: DEFAULT_AI_MODEL,
+ };
+ return cfg[k];
+ });
+ return ctx;
+ }
+
+ it("returns 401 when signature invalid", async () => {
+ const ctx = await setupCtx();
+ const ep = getEndpoint(ctx.api, "POST", "/slash-command");
+ const res = makeRes();
+ await ep.handler!(
+ makeSlashReq(
+ { command: "/ai", text: "hello", user_id: "U1", channel_id: "C1" },
+ false,
+ ),
+ res as unknown as EndpointResponse,
+ );
+ expect(res._status).toBe(401);
+ });
+
+ it("returns usage message when text is empty", async () => {
+ const ctx = await setupCtx();
+ const ep = getEndpoint(ctx.api, "POST", "/slash-command");
+ const res = makeRes();
+ await ep.handler!(
+ makeSlashReq({
+ command: "/ai",
+ text: "",
+ user_id: "U1",
+ channel_id: "C1",
+ }),
+ res as unknown as EndpointResponse,
+ );
+ expect(res._status).toBe(200);
+ const body = res._body as { response_type: string; text: string };
+ expect(body.response_type).toBe("ephemeral");
+ expect(body.text).toContain("/ai");
+ });
+
+ it("returns ephemeral error for unknown command", async () => {
+ const ctx = await setupCtx();
+ const ep = getEndpoint(ctx.api, "POST", "/slash-command");
+ const res = makeRes();
+ await ep.handler!(
+ makeSlashReq({
+ command: "/unknown",
+ text: "hello",
+ user_id: "U1",
+ channel_id: "C1",
+ }),
+ res as unknown as EndpointResponse,
+ );
+ expect(res._status).toBe(200);
+ expect((res._body as { response_type: string }).response_type).toBe(
+ "ephemeral",
+ );
+ });
+
+ it("returns thinking message and fires async AI call", async () => {
+ const ctx = await setupCtx();
+ (ctx.api.makeRequest as jest.Mock).mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: jest.fn().mockResolvedValue({
+ choices: [{ message: { content: "Smart answer" } }],
+ ok: true,
+ }),
+ });
+
+ const ep = getEndpoint(ctx.api, "POST", "/slash-command");
+ const res = makeRes();
+ await ep.handler!(
+ makeSlashReq({
+ command: "/ai",
+ text: "What is AGI?",
+ user_id: "U1",
+ channel_id: "C_TEST",
+ }),
+ res as unknown as EndpointResponse,
+ );
+
+ expect(res._status).toBe(200);
+ expect((res._body as { response_type: string }).response_type).toBe(
+ "in_channel",
+ );
+ const bodyText = (res._body as { text: string }).text;
+ expect(bodyText).toContain("Thinking");
+ });
+
+ it("uses response_url for async reply when provided", async () => {
+ const ctx = await setupCtx();
+ const responseUrl = "https://hooks.slack.com/commands/T1/B1/token";
+ let urlPostedTo: string | undefined;
+ (ctx.api.makeRequest as jest.Mock)
+ .mockResolvedValueOnce({
+ // AI call
+ ok: true,
+ status: 200,
+ json: jest.fn().mockResolvedValue({
+ choices: [{ message: { content: "AI reply" } }],
+ }),
+ })
+ .mockImplementation(async (url: string) => {
+ urlPostedTo = url;
+ return {
+ ok: true,
+ status: 200,
+ json: jest.fn().mockResolvedValue({ ok: true }),
+ };
+ });
+
+ const ep = getEndpoint(ctx.api, "POST", "/slash-command");
+ const res = makeRes();
+ await ep.handler!(
+ makeSlashReq({
+ command: "/ai",
+ text: "Tell me something",
+ user_id: "U1",
+ channel_id: "C1",
+ response_url: responseUrl,
+ }),
+ res as unknown as EndpointResponse,
+ );
+
+ // Wait a tick for the fire-and-forget async work
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ expect(urlPostedTo).toBe(responseUrl);
+ });
+
+ it("logs error and does not throw when async AI reply fails", async () => {
+ const ctx = await setupCtx();
+ (ctx.api.makeRequest as jest.Mock).mockResolvedValue({
+ ok: false,
+ status: 500,
+ });
+
+ const ep = getEndpoint(ctx.api, "POST", "/slash-command");
+ const res = makeRes();
+ await ep.handler!(
+ makeSlashReq({
+ command: "/ai",
+ text: "fail me",
+ user_id: "U1",
+ channel_id: "C1",
+ }),
+ res as unknown as EndpointResponse,
+ );
+
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ expect(ctx.api.log).toHaveBeenCalledWith(
+ expect.stringContaining("Slash command AI reply failed"),
+ "error",
+ );
+ });
+});
diff --git a/packages/plugins/official/slack-connector/__tests__/tsconfig.json b/packages/plugins/official/slack-connector/__tests__/tsconfig.json
new file mode 100644
index 0000000..a05feed
--- /dev/null
+++ b/packages/plugins/official/slack-connector/__tests__/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "extends": "../tsconfig.json",
+ "compilerOptions": {
+ "rootDir": "../..",
+ "baseUrl": "..",
+ "lib": ["ES2022", "DOM"],
+ "types": ["jest", "node"],
+ "paths": {
+ "@agentbase/plugin-sdk": ["../../../sdk/src/index.ts"]
+ }
+ },
+ "include": ["../src/**/*", "./**/*"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/packages/plugins/official/slack-connector/manifest.json b/packages/plugins/official/slack-connector/manifest.json
new file mode 100644
index 0000000..25b27fb
--- /dev/null
+++ b/packages/plugins/official/slack-connector/manifest.json
@@ -0,0 +1,10 @@
+{
+ "name": "slack-connector",
+ "version": "1.0.0",
+ "description": "Connect your Agentbase AI to Slack — @mentions, slash commands, and AI-powered channel responses.",
+ "entryPoint": "dist/index.js",
+ "author": "Agentbase Team",
+ "agentbaseVersion": ">=1.0.0",
+ "permissions": ["network:external", "db:readwrite"],
+ "peerDependencies": {}
+}
diff --git a/packages/plugins/official/slack-connector/package.json b/packages/plugins/official/slack-connector/package.json
new file mode 100644
index 0000000..53d0d46
--- /dev/null
+++ b/packages/plugins/official/slack-connector/package.json
@@ -0,0 +1,41 @@
+{
+ "name": "@agentbase/plugin-slack-connector",
+ "version": "1.0.0",
+ "description": "Connect your Agentbase AI to Slack — @mentions, slash commands, and AI-powered channel responses.",
+ "private": true,
+ "main": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "license": "GPL-3.0-or-later",
+ "scripts": {
+ "build": "tsc",
+ "test": "jest --passWithNoTests",
+ "test:cov": "jest --coverage --passWithNoTests"
+ },
+ "dependencies": {
+ "@agentbase/plugin-sdk": "workspace:*"
+ },
+ "devDependencies": {
+ "@types/jest": "^29.5.0",
+ "@types/node": "^25.5.2",
+ "jest": "^29.7.0",
+ "ts-jest": "^29.2.0",
+ "typescript": "^5.7.0"
+ },
+ "jest": {
+ "preset": "ts-jest",
+ "testEnvironment": "node",
+ "testMatch": [
+ "**/__tests__/**/*.test.ts"
+ ],
+ "globals": {
+ "ts-jest": {
+ "tsconfig": "./tsconfig.test.json"
+ }
+ },
+ "coverageThreshold": {
+ "global": {
+ "lines": 80
+ }
+ }
+ }
+}
diff --git a/packages/plugins/official/slack-connector/src/index.ts b/packages/plugins/official/slack-connector/src/index.ts
new file mode 100644
index 0000000..07d5286
--- /dev/null
+++ b/packages/plugins/official/slack-connector/src/index.ts
@@ -0,0 +1,588 @@
+/**
+ * Slack Connector
+ *
+ * Connect your Agentbase AI app to Slack. Handles incoming Slack Events API
+ * payloads (app_mention, message, url_verification), slash commands, and
+ * provides channel management. All external calls use `makeRequest`.
+ *
+ * Security:
+ * - Every inbound Slack request is verified with HMAC-SHA256 using the signing
+ * secret before any data is processed.
+ * - Replay-attack prevention: requests older than 5 minutes are rejected.
+ * - Event deduplication via `event_id` stored in plugin DB.
+ *
+ * Async response pattern:
+ * - Webhook handler acknowledges Slack within 3 s by sending an immediate
+ * HTTP 200, then fires AI completion + chat.postMessage asynchronously so
+ * Slack never times out.
+ *
+ * @package @agentbase/plugin-slack-connector
+ * @version 1.0.0
+ */
+import { createPlugin, PluginContext } from "@agentbase/plugin-sdk";
+import { createHmac, timingSafeEqual } from "crypto";
+
+// ── Constants ─────────────────────────────────────────────────────────────────
+
+export const SLACK_API_BASE = "https://slack.com/api";
+export const AI_COMPLETIONS_PATH = "/api/v1/internal/ai/completions";
+
+/** Maximum age (ms) of a Slack request before it is rejected as a replay. */
+export const MAX_REQUEST_AGE_MS = 5 * 60 * 1000; // 5 minutes
+
+/** How long to cache dedup event IDs (ms). */
+export const EVENT_DEDUP_TTL_MS = 10 * 60 * 1000; // 10 minutes
+
+export const DEFAULT_SLASH_COMMAND = "/ai";
+export const DEFAULT_AI_MODEL = "gpt-4o";
+
+// ── Types ─────────────────────────────────────────────────────────────────────
+
+export interface SlackChannel {
+ id: string;
+ name: string;
+ is_private: boolean;
+ is_archived: boolean;
+ num_members?: number;
+}
+
+export interface SlackEventPayload {
+ type: string;
+ token?: string;
+ challenge?: string;
+ event_id?: string;
+ event_time?: number;
+ team_id?: string;
+ event?: {
+ type: string;
+ user?: string;
+ text?: string;
+ channel?: string;
+ ts?: string;
+ bot_id?: string;
+ app_mention?: boolean;
+ };
+}
+
+export interface SlackSlashCommandPayload {
+ command: string;
+ text: string;
+ user_id: string;
+ user_name?: string;
+ channel_id: string;
+ channel_name?: string;
+ team_id?: string;
+ response_url?: string;
+}
+
+export interface ChannelConfig {
+ channelId: string;
+ name?: string;
+ connectedAt: number;
+}
+
+export interface DedupRecord {
+ eventId: string;
+ receivedAt: number;
+}
+
+// ── DB Key Helpers ────────────────────────────────────────────────────────────
+
+export function buildChannelKey(channelId: string): string {
+ return `channel:${channelId}`;
+}
+
+export function buildMessageKey(ts: string): string {
+ return `message:${ts}`;
+}
+
+export function buildConnectionKey(): string {
+ return "connection:config";
+}
+
+// ── Slack Signature Verification ──────────────────────────────────────────────
+
+/**
+ * Verify a Slack `X-Slack-Signature` header using HMAC-SHA256.
+ *
+ * Slack signs each request as:
+ * `v0=HMAC_SHA256(signingSecret, "v0:{timestamp}:{rawBody}")`
+ *
+ * @param signingSecret The Slack app signing secret.
+ * @param rawBody The raw (unparsed) request body string.
+ * @param timestamp Value of `X-Slack-Request-Timestamp` header.
+ * @param signature Value of `X-Slack-Signature` header.
+ * @param nowMs Current epoch ms (injectable for testing).
+ */
+export function verifySlackSignature(
+ signingSecret: string,
+ rawBody: string,
+ timestamp: string | undefined,
+ signature: string | undefined,
+ nowMs: number = Date.now(),
+): boolean {
+ if (!timestamp || !signature) return false;
+ if (!signature.startsWith("v0=")) return false;
+
+ // Reject replays older than 5 minutes
+ const tsMs = parseInt(timestamp, 10) * 1000;
+ if (Math.abs(nowMs - tsMs) > MAX_REQUEST_AGE_MS) return false;
+
+ const sigBaseString = `v0:${timestamp}:${rawBody}`;
+ const hmac = createHmac("sha256", signingSecret);
+ hmac.update(sigBaseString, "utf8");
+ const expected = `v0=${hmac.digest("hex")}`;
+
+ const receivedHex = signature.slice("v0=".length);
+ const expectedHex = expected.slice("v0=".length);
+
+ if (receivedHex.length !== expectedHex.length) return false;
+ return timingSafeEqual(Buffer.from(receivedHex), Buffer.from(expectedHex));
+}
+
+// ── Slack API Helpers ─────────────────────────────────────────────────────────
+
+interface SlackApiResponse {
+ ok: boolean;
+ error?: string;
+}
+
+interface SlackConversationsListResponse extends SlackApiResponse {
+ channels?: SlackChannel[];
+}
+
+/**
+ * Call a Slack Web API method with a Bearer token.
+ */
+export async function slackApiCall(
+ makeRequest: (url: string, opts?: RequestInit) => Promise,
+ token: string,
+ method: string,
+ params: Record = {},
+): Promise {
+ const url = new URL(`${SLACK_API_BASE}/${method}`);
+ for (const [k, v] of Object.entries(params)) {
+ url.searchParams.set(k, String(v));
+ }
+
+ const response = await makeRequest(url.toString(), {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+
+ if (!response.ok) {
+ throw new Error(`Slack API HTTP error: ${response.status}`);
+ }
+
+ const data = (await response.json()) as T;
+ if (!data.ok) {
+ throw new Error(`Slack API error: ${data.error ?? "unknown"}`);
+ }
+ return data;
+}
+
+/**
+ * Post a message to a Slack channel using `chat.postMessage`.
+ */
+export async function postSlackMessage(
+ makeRequest: (url: string, opts?: RequestInit) => Promise,
+ token: string,
+ channel: string,
+ text: string,
+): Promise {
+ const response = await makeRequest(`${SLACK_API_BASE}/chat.postMessage`, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${token}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ channel, text }),
+ });
+
+ if (!response.ok) {
+ throw new Error(`chat.postMessage HTTP error: ${response.status}`);
+ }
+
+ const data = (await response.json()) as SlackApiResponse;
+ if (!data.ok) {
+ throw new Error(`chat.postMessage error: ${data.error ?? "unknown"}`);
+ }
+}
+
+// ── AI Completion ─────────────────────────────────────────────────────────────
+
+/**
+ * Ask the platform AI service a question and return the text response.
+ */
+export async function askAI(
+ makeRequest: (url: string, opts?: RequestInit) => Promise,
+ message: string,
+ model: string = DEFAULT_AI_MODEL,
+): Promise {
+ const response = await makeRequest(AI_COMPLETIONS_PATH, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ model,
+ messages: [{ role: "user", content: message }],
+ temperature: 0.7,
+ }),
+ });
+
+ if (!response.ok) {
+ throw new Error(`AI service error: ${response.status}`);
+ }
+
+ const data = (await response.json()) as Record;
+
+ const choices = data["choices"] as
+ | Array<{ message: { content: string } }>
+ | undefined;
+ if (choices?.[0]?.message?.content) return choices[0].message.content;
+ if (typeof data["content"] === "string") return data["content"] as string;
+
+ throw new Error("Unexpected AI response shape");
+}
+
+// ── Strip Slack mention ───────────────────────────────────────────────────────
+
+/**
+ * Remove Slack user/bot mention markup (`<@UXXXXXXX>`) from the beginning
+ * of a message and return the cleaned text.
+ */
+export function stripMention(text: string): string {
+ return text.replace(/^<@[A-Z0-9]+>\s*/i, "").trim();
+}
+
+// ── Plugin Definition ─────────────────────────────────────────────────────────
+
+export default createPlugin({
+ name: "slack-connector",
+ version: "1.0.0",
+ description:
+ "Connect your Agentbase AI to Slack — @mentions, slash commands, and AI-powered channel responses.",
+ permissions: ["network:external", "db:readwrite"],
+ settings: {
+ slackBotToken: {
+ type: "string",
+ label: "Slack Bot Token (xoxb-…)",
+ encrypted: true,
+ },
+ slackSigningSecret: {
+ type: "string",
+ label: "Slack Signing Secret",
+ encrypted: true,
+ },
+ defaultChannel: {
+ type: "string",
+ label: "Default Channel ID",
+ },
+ listenForMentions: {
+ type: "boolean",
+ label: "Respond to @mentions",
+ default: true,
+ },
+ slashCommand: {
+ type: "string",
+ label: "Slash Command",
+ default: DEFAULT_SLASH_COMMAND,
+ },
+ aiModel: {
+ type: "select",
+ label: "AI Model",
+ default: DEFAULT_AI_MODEL,
+ options: [
+ "gpt-4o",
+ "gpt-4o-mini",
+ "claude-3-5-sonnet",
+ "gemini-2-0-flash",
+ ],
+ },
+ },
+
+ hooks: {
+ "app:init": async (context) => {
+ const { api } = context;
+
+ // ── POST /connect ───────────────────────────────────────────────────────
+ api.registerEndpoint({
+ method: "POST",
+ path: "/connect",
+ auth: true,
+ description: "Store Slack bot token and signing secret",
+ handler: async (req, res) => {
+ const { botToken, signingSecret } = req.body as {
+ botToken?: string;
+ signingSecret?: string;
+ };
+
+ if (!botToken || !signingSecret) {
+ res
+ .status(400)
+ .json({ error: "botToken and signingSecret are required" });
+ return;
+ }
+
+ // Validate the token by calling auth.test
+ try {
+ await slackApiCall(api.makeRequest, botToken, "auth.test");
+ } catch (err) {
+ res.status(400).json({
+ error: `Invalid bot token: ${(err as Error).message}`,
+ });
+ return;
+ }
+
+ await api.db.set(buildConnectionKey(), {
+ botToken,
+ signingSecret,
+ connectedAt: Date.now(),
+ });
+
+ res.status(200).json({ connected: true });
+ },
+ });
+
+ // ── GET /channels ───────────────────────────────────────────────────────
+ api.registerEndpoint({
+ method: "GET",
+ path: "/channels",
+ auth: true,
+ description: "List Slack channels the bot has access to",
+ handler: async (_req, res) => {
+ const token = (api.getConfig("slackBotToken") as string) ?? "";
+ if (!token) {
+ res.status(400).json({ error: "Slack bot token not configured" });
+ return;
+ }
+
+ try {
+ const data = await slackApiCall(
+ api.makeRequest,
+ token,
+ "conversations.list",
+ { types: "public_channel,private_channel", limit: 200 },
+ );
+ res.status(200).json({ channels: data.channels ?? [] });
+ } catch (err) {
+ res.status(502).json({ error: (err as Error).message });
+ }
+ },
+ });
+
+ // ── POST /webhook ───────────────────────────────────────────────────────
+ api.registerEndpoint({
+ method: "POST",
+ path: "/webhook",
+ auth: false,
+ description: "Slack Events API receiver",
+ handler: async (req, res) => {
+ const signingSecret =
+ (api.getConfig("slackSigningSecret") as string) ?? "";
+
+ // Raw body reconstruction for signature verification
+ const rawBody =
+ typeof req.body === "string" ? req.body : JSON.stringify(req.body);
+
+ const timestamp = req.headers["x-slack-request-timestamp"] as
+ | string
+ | undefined;
+ const signature = req.headers["x-slack-signature"] as
+ | string
+ | undefined;
+
+ if (
+ signingSecret &&
+ !verifySlackSignature(signingSecret, rawBody, timestamp, signature)
+ ) {
+ res.status(401).json({ error: "Invalid Slack signature" });
+ return;
+ }
+
+ const payload =
+ typeof req.body === "string"
+ ? (JSON.parse(req.body) as SlackEventPayload)
+ : (req.body as SlackEventPayload);
+
+ // URL verification challenge (Slack sends this when you first configure the endpoint)
+ if (payload.type === "url_verification") {
+ res.status(200).json({ challenge: payload.challenge });
+ return;
+ }
+
+ // Deduplicate events using event_id
+ if (payload.event_id) {
+ const dedupKey = buildMessageKey(payload.event_id);
+ const existing = await api.db.get(dedupKey);
+ if (existing) {
+ res.status(200).json({ ok: true, deduplicated: true });
+ return;
+ }
+ await api.db.set(dedupKey, {
+ eventId: payload.event_id,
+ receivedAt: Date.now(),
+ } satisfies DedupRecord);
+ }
+
+ const event = payload.event;
+ if (!event) {
+ res.status(200).json({ ok: true });
+ return;
+ }
+
+ // Ignore bot messages to prevent loops
+ if (event.bot_id) {
+ res.status(200).json({ ok: true });
+ return;
+ }
+
+ const token = (api.getConfig("slackBotToken") as string) ?? "";
+ const listenForMentions =
+ (api.getConfig("listenForMentions") as boolean) ?? true;
+ const aiModel =
+ (api.getConfig("aiModel") as string) ?? DEFAULT_AI_MODEL;
+
+ const isMention = event.type === "app_mention";
+ const isDirectMessage =
+ event.type === "message" && event.channel?.startsWith("D");
+
+ if (!listenForMentions && !isDirectMessage) {
+ res.status(200).json({ ok: true });
+ return;
+ }
+
+ if (isMention || isDirectMessage) {
+ const userText = stripMention(event.text ?? "").trim();
+ const replyChannel =
+ event.channel ??
+ (api.getConfig("defaultChannel") as string) ??
+ "";
+
+ if (!userText || !replyChannel || !token) {
+ res.status(200).json({ ok: true });
+ return;
+ }
+
+ // Acknowledge immediately; respond asynchronously
+ res.status(200).json({ ok: true });
+
+ // Fire-and-forget async AI + postMessage
+ (async () => {
+ try {
+ const aiReply = await askAI(api.makeRequest, userText, aiModel);
+ await postSlackMessage(
+ api.makeRequest,
+ token,
+ replyChannel,
+ aiReply,
+ );
+ } catch (err) {
+ api.log(
+ `Slack async reply failed: ${(err as Error).message}`,
+ "error",
+ );
+ }
+ })();
+ } else {
+ res.status(200).json({ ok: true });
+ }
+ },
+ });
+
+ // ── POST /slash-command ─────────────────────────────────────────────────
+ api.registerEndpoint({
+ method: "POST",
+ path: "/slash-command",
+ auth: false,
+ description: "Slack slash command handler",
+ handler: async (req, res) => {
+ const signingSecret =
+ (api.getConfig("slackSigningSecret") as string) ?? "";
+ const rawBody =
+ typeof req.body === "string" ? req.body : JSON.stringify(req.body);
+
+ const timestamp = req.headers["x-slack-request-timestamp"] as
+ | string
+ | undefined;
+ const signature = req.headers["x-slack-signature"] as
+ | string
+ | undefined;
+
+ if (
+ signingSecret &&
+ !verifySlackSignature(signingSecret, rawBody, timestamp, signature)
+ ) {
+ res.status(401).json({ error: "Invalid Slack signature" });
+ return;
+ }
+
+ const payload = req.body as SlackSlashCommandPayload;
+ const configuredCommand =
+ (api.getConfig("slashCommand") as string) ?? DEFAULT_SLASH_COMMAND;
+
+ if (
+ payload.command &&
+ payload.command !== configuredCommand &&
+ payload.command !== "/ai"
+ ) {
+ res.status(200).json({
+ response_type: "ephemeral",
+ text: `Unknown command '${payload.command}'`,
+ });
+ return;
+ }
+
+ const userText = (payload.text ?? "").trim();
+ if (!userText) {
+ res.status(200).json({
+ response_type: "ephemeral",
+ text: `Usage: ${configuredCommand} `,
+ });
+ return;
+ }
+
+ const token = (api.getConfig("slackBotToken") as string) ?? "";
+ const aiModel =
+ (api.getConfig("aiModel") as string) ?? DEFAULT_AI_MODEL;
+
+ // Acknowledge immediately with a "thinking…" message
+ res.status(200).json({
+ response_type: "in_channel",
+ text: `_Thinking…_`,
+ });
+
+ // Fire-and-forget: send the real AI reply via response_url if available,
+ // otherwise post to channel
+ (async () => {
+ try {
+ const aiReply = await askAI(api.makeRequest, userText, aiModel);
+
+ if (payload.response_url) {
+ await api.makeRequest(payload.response_url, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ response_type: "in_channel",
+ replace_original: true,
+ text: aiReply,
+ }),
+ });
+ } else if (token && payload.channel_id) {
+ await postSlackMessage(
+ api.makeRequest,
+ token,
+ payload.channel_id,
+ aiReply,
+ );
+ }
+ } catch (err) {
+ api.log(
+ `Slash command AI reply failed: ${(err as Error).message}`,
+ "error",
+ );
+ }
+ })();
+ },
+ });
+ },
+ },
+});
diff --git a/packages/plugins/official/slack-connector/tsconfig.json b/packages/plugins/official/slack-connector/tsconfig.json
new file mode 100644
index 0000000..7f8ba5d
--- /dev/null
+++ b/packages/plugins/official/slack-connector/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "extends": "../../../../tsconfig.json",
+ "compilerOptions": {
+ "target": "ES2020",
+ "module": "commonjs",
+ "outDir": "./dist",
+ "rootDir": "../..",
+ "baseUrl": ".",
+ "declaration": true,
+ "declarationMap": true,
+ "sourceMap": true,
+ "strict": true,
+ "noEmit": true,
+ "types": ["node"],
+ "paths": {
+ "@agentbase/plugin-sdk": ["../../sdk/src/index.ts"]
+ }
+ },
+ "include": ["src/**/*"],
+ "exclude": ["node_modules", "dist", "__tests__"]
+}
diff --git a/packages/plugins/official/slack-connector/tsconfig.test.json b/packages/plugins/official/slack-connector/tsconfig.test.json
new file mode 100644
index 0000000..1801816
--- /dev/null
+++ b/packages/plugins/official/slack-connector/tsconfig.test.json
@@ -0,0 +1,14 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "rootDir": "../..",
+ "baseUrl": ".",
+ "lib": ["ES2022", "DOM"],
+ "types": ["jest", "node"],
+ "paths": {
+ "@agentbase/plugin-sdk": ["../../sdk/src/index.ts"]
+ }
+ },
+ "include": ["src/**/*", "__tests__/**/*"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index c8b0137..ca1795b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -444,6 +444,28 @@ importers:
specifier: ^5.7.0
version: 5.9.3
+ packages/plugins/official/slack-connector:
+ dependencies:
+ '@agentbase/plugin-sdk':
+ specifier: workspace:*
+ version: link:../..
+ devDependencies:
+ '@types/jest':
+ specifier: ^29.5.0
+ version: 29.5.14
+ '@types/node':
+ specifier: ^25.5.2
+ version: 25.5.2
+ jest:
+ specifier: ^29.7.0
+ version: 29.7.0(@types/node@25.5.2)
+ ts-jest:
+ specifier: ^29.2.0
+ version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2))(typescript@5.9.3)
+ typescript:
+ specifier: ^5.7.0
+ version: 5.9.3
+
packages/plugins/template:
dependencies:
'@agentbase/plugin-sdk':