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':