From 73b75d80ff68f640d1191ce88cb0568e2399d871 Mon Sep 17 00:00:00 2001 From: DeWitt Gibson Date: Mon, 6 Apr 2026 13:59:25 -0700 Subject: [PATCH] Add slack-connector plugin and tests Introduce a new slack-connector plugin (packages/plugins/official/slack-connector). Adds full TypeScript implementation (src/index.ts) providing POST /connect, GET /channels, POST /webhook, and POST /slash-command endpoints, Slack signature verification with replay protection, event deduplication, Slack API helpers, and async AI reply handling. Includes manifest, package.json, tsconfig files, and comprehensive Jest unit tests covering signature verification, API calls, webhook and slash-command behaviors. --- .../slack-connector/__tests__/index.test.ts | 982 ++++++++++++++++++ .../slack-connector/__tests__/tsconfig.json | 14 + .../official/slack-connector/manifest.json | 10 + .../official/slack-connector/package.json | 41 + .../official/slack-connector/src/index.ts | 588 +++++++++++ .../official/slack-connector/tsconfig.json | 21 + .../slack-connector/tsconfig.test.json | 14 + pnpm-lock.yaml | 22 + 8 files changed, 1692 insertions(+) create mode 100644 packages/plugins/official/slack-connector/__tests__/index.test.ts create mode 100644 packages/plugins/official/slack-connector/__tests__/tsconfig.json create mode 100644 packages/plugins/official/slack-connector/manifest.json create mode 100644 packages/plugins/official/slack-connector/package.json create mode 100644 packages/plugins/official/slack-connector/src/index.ts create mode 100644 packages/plugins/official/slack-connector/tsconfig.json create mode 100644 packages/plugins/official/slack-connector/tsconfig.test.json 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':