From 5472de4bb4a2787599c6c4011d22e7fd7ece86f3 Mon Sep 17 00:00:00 2001 From: John Smith Date: Sat, 28 Dec 2024 15:32:43 +1030 Subject: [PATCH] feat: Add Slack unintall flow --- .../[clusterId]/integrations/slack/page.tsx | 54 ++++--- app/client/contract.ts | 30 +++- control-plane/package-lock.json | 142 ++---------------- control-plane/package.json | 2 +- control-plane/src/modules/contract.ts | 25 ++- .../src/modules/integrations/integrations.ts | 22 +-- .../src/modules/integrations/nango/index.ts | 26 +++- .../src/modules/integrations/router.ts | 84 ++++++----- .../src/modules/integrations/slack/index.ts | 54 ++++++- .../src/modules/integrations/tavily.ts | 4 +- .../src/modules/integrations/toolhouse.ts | 4 +- .../src/modules/integrations/types.ts | 5 +- .../src/modules/integrations/valtown.ts | 4 +- control-plane/src/modules/jobs/external.ts | 4 +- control-plane/src/utilities/env.ts | 2 + 15 files changed, 242 insertions(+), 220 deletions(-) diff --git a/app/app/clusters/[clusterId]/integrations/slack/page.tsx b/app/app/clusters/[clusterId]/integrations/slack/page.tsx index 2f9d9190..19aa9edd 100644 --- a/app/app/clusters/[clusterId]/integrations/slack/page.tsx +++ b/app/app/clusters/[clusterId]/integrations/slack/page.tsx @@ -17,6 +17,8 @@ import { Loading } from "@/components/loading"; import Nango from '@nangohq/frontend'; import toast from "react-hot-toast"; import { useRouter } from "next/navigation"; +import { ClientInferResponses } from "@ts-rest/core"; +import { contract } from "@/client/contract"; const nango = new Nango(); @@ -28,8 +30,10 @@ export default function SlackIntegration({ const { getToken } = useAuth(); const router = useRouter(); const [loading, setLoading] = useState(false); - const [sessionToken, setSessionToken] = useState(null); - const [connectionId, setConnectionId] = useState(null); + const [connection, setConnection] = useState["body"]["slack"] | null>(null); const fetchConfig = useCallback(async () => { setLoading(true); @@ -44,19 +48,31 @@ export default function SlackIntegration({ setLoading(false); if (response.status === 200) { - setSessionToken(response.body?.slack?.nangoSessionToken ?? null); - setConnectionId(response.body?.slack?.nangoConnectionId ?? null); + setConnection(response.body?.slack); } }, [clusterId, getToken]); const onSlackConnect = async () => { - if (!sessionToken) { + const response = await client.createNangoSession({ + headers: { + authorization: `Bearer ${await getToken()}`, + }, + params: { + clusterId: clusterId, + }, + body: { + integration: "slack", + } + }); + + if (response.status !== 200 || !response.body || !response.body.token) { + toast.error("Failed to connect to Slack"); return; } nango.openConnectUI({ - sessionToken: sessionToken, + sessionToken: response.body.token, onEvent: async (event) => { if (event.type === "connect") { toast.success("Connected to Slack"); @@ -106,21 +122,21 @@ export default function SlackIntegration({ - {sessionToken ? ( -
- -
- ) : null} - {connectionId ? ( + {connection ? (
-

Connected ({connectionId})

+

Slack Connected (Team: {connection.teamId})

- ) : null} + ) : ( +
+ +
+ ) + }
diff --git a/app/client/contract.ts b/app/client/contract.ts index 478e1a07..e244decb 100644 --- a/app/client/contract.ts +++ b/app/client/contract.ts @@ -82,8 +82,9 @@ export const integrationSchema = z.object({ .nullable(), slack: z .object({ - nangoSessionToken: z.string().optional().nullable(), - nangoConnectionId: z.string().optional().nullable(), + nangoConnectionId: z.string(), + botUserId: z.string(), + teamId: z.string(), }) .optional() .nullable(), @@ -1489,6 +1490,31 @@ export const definition = { }), }, }, + createNangoSession: { + method: "POST", + path: "/clusters/:clusterId/nango/sessions", + pathParams: z.object({ + clusterId: z.string(), + }), + headers: z.object({ authorization: z.string() }), + body: z.object({ + integration: z.string(), + }), + responses: { + 200: z.object({ + token: z.string(), + }), + }, + }, + createNangoEvent: { + method: "POST", + path: "/nango/events", + headers: z.object({ "x-nango-signature": z.string() }), + body: z.object({}).passthrough(), + responses: { + 200: z.undefined(), + }, + }, } as const; export const contract = c.router(definition); diff --git a/control-plane/package-lock.json b/control-plane/package-lock.json index 18237499..ef84cc04 100644 --- a/control-plane/package-lock.json +++ b/control-plane/package-lock.json @@ -18,7 +18,6 @@ "@langchain/cohere": "^0.3.1", "@langchain/langgraph": "^0.1.9", "@nangohq/node": "^0.48.1", - "@openapi-contrib/openapi-schema-to-json-schema": "^5.1.0", "@slack/bolt": "^4.1.1", "@toolhouseai/sdk": "^1.0.4", "@ts-rest/core": "^3.27.0", @@ -60,6 +59,7 @@ "devDependencies": { "@babel/preset-env": "^7.22.10", "@babel/preset-typescript": "^7.22.11", + "@nangohq/types": "^0.48.1", "@types/async-retry": "^1.4.9", "@types/jest": "^29.5.14", "@types/jsonpath": "^0.2.4", @@ -6937,15 +6937,6 @@ "integrity": "sha512-ypuAmmMKInk5q7XcepxlnUWDLWv4GFtaJqAzWKqn62IpQ3pejtr5dTVbt3vwqVaMKmkNR55sTT+CqUKIaT21BA==", "license": "MIT" }, - "node_modules/@fastify/busboy": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", - "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", - "license": "MIT", - "engines": { - "node": ">=14" - } - }, "node_modules/@fastify/cors": { "version": "8.5.0", "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-8.5.0.tgz", @@ -7767,6 +7758,13 @@ "node": ">=18.0" } }, + "node_modules/@nangohq/types": { + "version": "0.48.1", + "resolved": "https://registry.npmjs.org/@nangohq/types/-/types-0.48.1.tgz", + "integrity": "sha512-qHubw5zLQo6oreMChZmDMmFAWQyT5aVB8F1p0aZhzuTlep9j35nMNzBEEdTH2xZVsP58LZ2KrQJl+kHtOeVmFQ==", + "dev": true, + "license": "SEE LICENSE IN LICENSE FILE IN GIT REPOSITORY" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -7805,42 +7803,6 @@ "node": ">= 8" } }, - "node_modules/@openapi-contrib/openapi-schema-to-json-schema": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@openapi-contrib/openapi-schema-to-json-schema/-/openapi-schema-to-json-schema-5.1.0.tgz", - "integrity": "sha512-MJnq+CxD8JAufiJoa8RK6D/8P45MEBe0teUi30TNoHRrI6MZRNgetK2Y2IfDXWGLTHMopb1d9GHonqlV2Yvztg==", - "license": "MIT", - "dependencies": { - "@types/json-schema": "^7.0.12", - "@types/lodash": "^4.14.195", - "@types/node": "^20.4.1", - "fast-deep-equal": "^3.1.3", - "lodash": "^4.17.21", - "openapi-typescript": "^5.4.1", - "yargs": "^17.7.2" - }, - "bin": { - "openapi-schema-to-json-schema": "dist/bin.js" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@openapi-contrib/openapi-schema-to-json-schema/node_modules/@types/node": { - "version": "20.17.8", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.8.tgz", - "integrity": "sha512-ahz2g6/oqbKalW9sPv6L2iRbhLnojxjYWspAqhjvqSWBgGebEJT5GvRmk0QXPj3sbC6rU0GTQjPLQkmR8CObvA==", - "license": "MIT", - "dependencies": { - "undici-types": "~6.19.2" - } - }, - "node_modules/@openapi-contrib/openapi-schema-to-json-schema/node_modules/undici-types": { - "version": "6.19.8", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", - "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", - "license": "MIT" - }, "node_modules/@opentelemetry/api": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.8.0.tgz", @@ -12750,6 +12712,7 @@ "version": "4.17.13", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.13.tgz", "integrity": "sha512-lfx+dftrEZcdBPczf9d0Qv0x+j/rfNCMuC6OcfXmO8gkfeNAY88PgKUbvG56whcN23gc27yenwF6oJZXGFpYxg==", + "dev": true, "license": "MIT" }, "node_modules/@types/memcached": { @@ -13543,7 +13506,9 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "license": "Python-2.0" + "dev": true, + "license": "Python-2.0", + "peer": true }, "node_modules/aria-query": { "version": "5.3.2", @@ -16331,12 +16296,6 @@ "node": ">=4" } }, - "node_modules/globalyzer": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/globalyzer/-/globalyzer-0.1.0.tgz", - "integrity": "sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q==", - "license": "MIT" - }, "node_modules/globby": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", @@ -16358,12 +16317,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/globrex": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz", - "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==", - "license": "MIT" - }, "node_modules/gopd": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", @@ -17496,7 +17449,9 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, "license": "MIT", + "peer": true, "dependencies": { "argparse": "^2.0.1" }, @@ -18182,18 +18137,6 @@ "node": ">=8.6" } }, - "node_modules/mime": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", - "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -18538,41 +18481,6 @@ "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", "license": "MIT" }, - "node_modules/openapi-typescript": { - "version": "5.4.2", - "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-5.4.2.tgz", - "integrity": "sha512-tHeRv39Yh7brqJpbUntdjtUaXrTHmC4saoyTLU/0J2I8LEFQYDXRLgnmWTMiMOB2GXugJiqHa5n9sAyd6BRqiA==", - "license": "MIT", - "dependencies": { - "js-yaml": "^4.1.0", - "mime": "^3.0.0", - "prettier": "^2.6.2", - "tiny-glob": "^0.2.9", - "undici": "^5.4.0", - "yargs-parser": "^21.0.1" - }, - "bin": { - "openapi-typescript": "bin/cli.js" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/openapi-typescript/node_modules/prettier": { - "version": "2.8.8", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", - "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", - "license": "MIT", - "bin": { - "prettier": "bin-prettier.js" - }, - "engines": { - "node": ">=10.13.0" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, "node_modules/openapi3-ts": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/openapi3-ts/-/openapi3-ts-2.0.2.tgz", @@ -20448,16 +20356,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/tiny-glob": { - "version": "0.2.9", - "resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.9.tgz", - "integrity": "sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==", - "license": "MIT", - "dependencies": { - "globalyzer": "0.1.0", - "globrex": "^0.1.2" - } - }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -21146,18 +21044,6 @@ "integrity": "sha512-hEQt0+ZLDVUMhebKxL4x1BTtDY7bavVofhZ9KZ4aI26X9SRaE+Y3m83XUL1UP2jn8ynjndwCCpEHdUG+9pP1Tw==", "license": "MIT" }, - "node_modules/undici": { - "version": "5.28.4", - "resolved": "https://registry.npmjs.org/undici/-/undici-5.28.4.tgz", - "integrity": "sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==", - "license": "MIT", - "dependencies": { - "@fastify/busboy": "^2.0.0" - }, - "engines": { - "node": ">=14.0" - } - }, "node_modules/undici-types": { "version": "6.20.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", diff --git a/control-plane/package.json b/control-plane/package.json index e52ae05f..7e8fc969 100644 --- a/control-plane/package.json +++ b/control-plane/package.json @@ -29,7 +29,6 @@ "@hyperdx/node-opentelemetry": "^0.8.1", "@langchain/cohere": "^0.3.1", "@langchain/langgraph": "^0.1.9", - "@openapi-contrib/openapi-schema-to-json-schema": "^5.1.0", "@nangohq/node": "^0.48.1", "@slack/bolt": "^4.1.1", "@toolhouseai/sdk": "^1.0.4", @@ -72,6 +71,7 @@ "devDependencies": { "@babel/preset-env": "^7.22.10", "@babel/preset-typescript": "^7.22.11", + "@nangohq/types": "^0.48.1", "@types/async-retry": "^1.4.9", "@types/jest": "^29.5.14", "@types/jsonpath": "^0.2.4", diff --git a/control-plane/src/modules/contract.ts b/control-plane/src/modules/contract.ts index 8c4b4c80..e244decb 100644 --- a/control-plane/src/modules/contract.ts +++ b/control-plane/src/modules/contract.ts @@ -82,8 +82,9 @@ export const integrationSchema = z.object({ .nullable(), slack: z .object({ - nangoSessionToken: z.string().optional().nullable(), - nangoConnectionId: z.string().optional().nullable(), + nangoConnectionId: z.string(), + botUserId: z.string(), + teamId: z.string(), }) .optional() .nullable(), @@ -1489,9 +1490,25 @@ export const definition = { }), }, }, - nangoWebhook: { + createNangoSession: { method: "POST", - path: "/integrations/nango", + path: "/clusters/:clusterId/nango/sessions", + pathParams: z.object({ + clusterId: z.string(), + }), + headers: z.object({ authorization: z.string() }), + body: z.object({ + integration: z.string(), + }), + responses: { + 200: z.object({ + token: z.string(), + }), + }, + }, + createNangoEvent: { + method: "POST", + path: "/nango/events", headers: z.object({ "x-nango-signature": z.string() }), body: z.object({}).passthrough(), responses: { diff --git a/control-plane/src/modules/integrations/integrations.ts b/control-plane/src/modules/integrations/integrations.ts index df250733..3c396cf1 100644 --- a/control-plane/src/modules/integrations/integrations.ts +++ b/control-plane/src/modules/integrations/integrations.ts @@ -2,24 +2,26 @@ import { eq, sql } from "drizzle-orm"; import { z } from "zod"; import { db, integrations } from "../data"; import { integrationSchema } from "./schema"; -import { tavilyIntegration, valtownIntegration, toolhouseIntegration } from "./constants"; +import { tavilyIntegration, valtownIntegration, toolhouseIntegration, slackIntegration } from "./constants"; import { tavily } from "./tavily"; import { toolhouse } from "./toolhouse"; import { valtown } from "./valtown"; -import { ToolProvider } from "./types"; +import { slack } from "./slack"; +import { InstallableIntegration } from "./types"; -const toolProviders: Record = { +const installableIntegrations: Record = { [toolhouseIntegration]: toolhouse, [tavilyIntegration]: tavily, [valtownIntegration]: valtown, + [slackIntegration]: slack, }; -export function getToolProvider(tool: string) { - if (!toolProviders[tool as keyof typeof toolProviders]) { +export function getInstallables(tool: string) { + if (!installableIntegrations[tool as keyof typeof installableIntegrations]) { throw new Error(`Unknown tool provider integration requested: ${tool}`); } - return toolProviders[tool as keyof typeof toolProviders]; + return installableIntegrations[tool as keyof typeof installableIntegrations]; } export const getIntegrations = async ({ @@ -56,6 +58,8 @@ export const upsertIntegrations = async ({ clusterId: string; config: z.infer; }) => { + const existing = await getIntegrations({ clusterId }); + await db .insert(integrations) .values({ @@ -74,12 +78,12 @@ export const upsertIntegrations = async ({ await Promise.all( Object.entries(config) - .filter(([key]) => toolProviders[key as keyof typeof toolProviders]) + .filter(([key]) => installableIntegrations[key as keyof typeof installableIntegrations]) .map(([key, value]) => { if (value) { - return getToolProvider(key)?.onActivate?.(clusterId, config); + return getInstallables(key)?.onActivate?.(clusterId, config); } else if (value === null) { - return getToolProvider(key)?.onDeactivate?.(clusterId, config); + return getInstallables(key)?.onDeactivate?.(clusterId, config, existing); } }) ); diff --git a/control-plane/src/modules/integrations/nango/index.ts b/control-plane/src/modules/integrations/nango/index.ts index 1f62591c..a016bf30 100644 --- a/control-plane/src/modules/integrations/nango/index.ts +++ b/control-plane/src/modules/integrations/nango/index.ts @@ -1,6 +1,8 @@ import { Nango } from "@nangohq/node"; import { env } from "../../../utilities/env"; import { z } from "zod"; +import { BadRequestError } from "../../../utilities/errors"; +import { logger } from "../../observability/logger"; export const nango = env.NANGO_SECRET_KEY && new Nango({ secretKey: env.NANGO_SECRET_KEY }); @@ -15,14 +17,6 @@ export const webhookSchema = z.object({ }) }) -export const slackConnectionSchema = z.object({ - connection_config: z.object({ - "team.id": z.string(), - "bot_user_id": z.string(), - }), -}) - - export const getSession = async ({ clusterId, integrationId, @@ -34,6 +28,22 @@ export const getSession = async ({ throw new Error("Nango is not configured"); } + const existing = await nango?.listConnections( + undefined, + undefined, + { + endUserId: clusterId, + } + ) + + if (existing?.connections.find((c) => c.provider_config_key === integrationId)) { + logger.warn("Attempted to create duplicate nango connection", { + integrationId, + existing: existing?.connections, + }); + throw new BadRequestError(`Nango ${integrationId} connection already exists for cluster`); + } + const res = await nango?.createConnectSession({ end_user: { id: clusterId, diff --git a/control-plane/src/modules/integrations/router.ts b/control-plane/src/modules/integrations/router.ts index b9ce5104..64966d5e 100644 --- a/control-plane/src/modules/integrations/router.ts +++ b/control-plane/src/modules/integrations/router.ts @@ -3,7 +3,7 @@ import { contract } from "../contract"; import { getIntegrations, upsertIntegrations } from "./integrations"; import { validateConfig } from "./toolhouse"; import { AuthenticationError, BadRequestError } from "../../utilities/errors"; -import { getSession, nango, slackConnectionSchema, webhookSchema } from "./nango"; +import { getSession, nango, webhookSchema } from "./nango"; import { env } from "../../utilities/env"; import { logger } from "../observability/logger"; @@ -11,7 +11,8 @@ export const integrationsRouter = initServer().router( { upsertIntegrations: contract.upsertIntegrations, getIntegrations: contract.getIntegrations, - nangoWebhook: contract.nangoWebhook, + createNangoSession: contract.createNangoSession, + createNangoEvent: contract.createNangoEvent, }, { upsertIntegrations: async (request) => { @@ -36,10 +37,7 @@ export const integrationsRouter = initServer().router( await upsertIntegrations({ clusterId, - config: { - ...request.body, - slack: undefined, - }, + config: request.body, }); return { @@ -58,23 +56,35 @@ export const integrationsRouter = initServer().router( clusterId, }); - if (!integrations.slack?.nangoConnectionId) { - integrations.slack = { - nangoSessionToken: await getSession({ - clusterId, - integrationId: env.NANGO_SLACK_INTEGRATION_ID, - }), - } as any; + return { + status: 200, + body: integrations, + }; + }, + createNangoSession: async (request) => { + if (!nango) { + throw new Error("Nango is not configured"); + } + + const { clusterId } = request.params; + const { integration } = request.body; + + if (integration !== env.NANGO_SLACK_INTEGRATION_ID) { + throw new BadRequestError("Invalid Nango integration ID"); } + const auth = request.request.getAuth(); + await auth.canAccess({ cluster: { clusterId } }); + auth.isAdmin(); + return { status: 200, body: { - ...integrations, - } - }; + token: await getSession({ clusterId, integrationId: env.NANGO_SLACK_INTEGRATION_ID }), + }, + } }, - nangoWebhook: async (request) => { + createNangoEvent: async (request) => { if (!nango) { throw new Error("Nango is not configured"); } @@ -104,30 +114,32 @@ export const integrationsRouter = initServer().router( && webhook.data.operation === "creation" && webhook.data.success ) { - const connectionResp = await nango.getConnection( + const connection = await nango.getConnection( webhook.data.providerConfigKey, webhook.data.connectionId, ); - const connection = slackConnectionSchema.safeParse(connectionResp); - - if (connection.success) { - logger.info("New Slack connection registered", { - connectionId: webhook.data.connectionId, - teamId: connection.data.connection_config["team.id"], - }); - - await upsertIntegrations({ - clusterId: webhook.data.endUser.endUserId, - config: { - slack: { - nangoConnectionId: webhook.data.connectionId, - teamId: connection.data.connection_config["team.id"], - botUserId: connection.data.connection_config["bot_user_id"], - }, - } - }) + logger.info("New Slack connection registered", { + connectionId: webhook.data.connectionId, + teamId: connection.connection_config["team.id"], + }); + + const clusterId = connection.end_user?.id; + + if (!clusterId) { + throw new BadRequestError("End user ID not found in Nango connection"); } + + await upsertIntegrations({ + clusterId, + config: { + slack: { + nangoConnectionId: webhook.data.connectionId, + teamId: connection.connection_config["team.id"], + botUserId: connection.connection_config["bot_user_id"], + }, + } + }) } return { diff --git a/control-plane/src/modules/integrations/slack/index.ts b/control-plane/src/modules/integrations/slack/index.ts index ddc9ed78..c6681cf6 100644 --- a/control-plane/src/modules/integrations/slack/index.ts +++ b/control-plane/src/modules/integrations/slack/index.ts @@ -10,6 +10,9 @@ import { ulid } from "ulid"; import { eq, InferSelectModel, sql } from "drizzle-orm"; import { db, integrations, workflowMessages } from "../../data"; import { nango } from "../nango"; +import { InstallableIntegration } from "../types"; +import { integrationSchema } from "../schema"; +import { z } from "zod"; let app: App | undefined; @@ -22,6 +25,24 @@ type MessageEvent = { clusterId: string; }; +export const slack: InstallableIntegration = { + name: "slack", + onDeactivate: async ( + clusterId: string, + newConfig: z.infer, + existingConfig: z.infer + ) => { + if (!existingConfig.slack?.nangoConnectionId) { + logger.warn("Can not uninstall Slack integration with no connection id") + } + await uninstall(clusterId, existingConfig.slack!.nangoConnectionId); + }, + onActivate: async () => {}, + handleCall: async () => { + logger.warn("Slack integration does not support calls"); + }, +} + export const handleNewRunMessage = async ({ message, metadata, @@ -90,16 +111,21 @@ export const start = async (fastify: FastifyInstance) => { throw new Error("Could not find Slack integration for teamId"); } + const token = await getAccessToken(integration.slack.nangoConnectionId) + if (!token) { + throw new Error(`Could not fetch access token for Slack integration: ${integration.slack.nangoConnectionId}`); + } + return { teamId, enterpriseId, botUserId: integration.slack.botUserId, - botToken: await getAccessToken(integration.slack.nangoConnectionId), + botToken: token, } }, receiver: new FastifySlackReceiver({ signingSecret: SLACK_SIGNING_SECRET, - path: "/integrations/slack", + path: "/slack/events", fastify, }), }); @@ -222,9 +248,31 @@ const getAccessToken = async (connectionId: string) => { throw new Error("Nango is not configured"); } - return await nango.getToken(env.NANGO_SLACK_INTEGRATION_ID, connectionId); + const result = await nango.getToken(env.NANGO_SLACK_INTEGRATION_ID, connectionId); + if (typeof result !== "string") { + return null; + } + + return result; +}; + +const uninstall = async (clusterId: string, connectionId: string) => { + if (!nango) { + throw new Error("Nango is not configured"); + } + + logger.info("Removing Slack integration", { + connectionId, + clusterId + }); + + await nango.deleteConnection( + env.NANGO_SLACK_INTEGRATION_ID, + connectionId + ); }; + const handleNewThread = async ({ event, client, clusterId }: MessageEvent) => { let thread = event.ts; // If this message is part of a thread, associate the run with the thread rather than the message diff --git a/control-plane/src/modules/integrations/tavily.ts b/control-plane/src/modules/integrations/tavily.ts index 832d8429..1121e0d7 100644 --- a/control-plane/src/modules/integrations/tavily.ts +++ b/control-plane/src/modules/integrations/tavily.ts @@ -5,7 +5,7 @@ import { logger } from "../observability/logger"; import { packer } from "../packer"; import { deleteServiceDefinition, upsertServiceDefinition } from "../service-definitions"; import { integrationSchema } from "./schema"; -import { ToolProvider } from "./types"; +import { InstallableIntegration } from "./types"; const TavilySearchParamsSchema = z.object({ query: z.string(), @@ -174,7 +174,7 @@ const handleCall = async ( } }; -export const tavily: ToolProvider = { +export const tavily: InstallableIntegration = { name: "Tavily", onActivate: async (clusterId: string, integrations: z.infer) => { await syncTavilyService({ diff --git a/control-plane/src/modules/integrations/toolhouse.ts b/control-plane/src/modules/integrations/toolhouse.ts index 251be20d..ce47dd7d 100644 --- a/control-plane/src/modules/integrations/toolhouse.ts +++ b/control-plane/src/modules/integrations/toolhouse.ts @@ -10,7 +10,7 @@ import { acknowledgeJob, getJob, persistJobResult } from "../jobs/jobs"; import { logger } from "../observability/logger"; import { packer } from "../packer"; import { upsertServiceDefinition } from "../service-definitions"; -import { ToolProvider } from "./types"; +import { InstallableIntegration } from "./types"; const ToolHouseResultSchema = z.array( z.object({ @@ -220,7 +220,7 @@ const toToolHouseName = (input: string) => { return input.replace(/([A-Z])/g, "_$1").toLowerCase(); }; -export const toolhouse: ToolProvider = { +export const toolhouse: InstallableIntegration = { name: "ToolHouse", onActivate: async (clusterId: string, config: z.infer) => { return syncToolHouseService({ diff --git a/control-plane/src/modules/integrations/types.ts b/control-plane/src/modules/integrations/types.ts index 3ae9a296..b7c7192c 100644 --- a/control-plane/src/modules/integrations/types.ts +++ b/control-plane/src/modules/integrations/types.ts @@ -2,12 +2,13 @@ import { z } from "zod"; import { integrationSchema } from "./schema"; import { getJob } from "../jobs/jobs"; -export type ToolProvider = { +export type InstallableIntegration = { name: string; onActivate: (clusterId: string, integrations: z.infer) => Promise; onDeactivate: ( clusterId: string, - integrations: z.infer + integrations: z.infer, + existing: z.infer ) => Promise; handleCall: ( call: NonNullable>>, diff --git a/control-plane/src/modules/integrations/valtown.ts b/control-plane/src/modules/integrations/valtown.ts index 217aa92f..b9d6ef75 100644 --- a/control-plane/src/modules/integrations/valtown.ts +++ b/control-plane/src/modules/integrations/valtown.ts @@ -6,7 +6,7 @@ import { logger } from "../observability/logger"; import { packer } from "../packer"; import { deleteServiceDefinition, upsertServiceDefinition } from "../service-definitions"; import { integrationSchema } from "./schema"; -import { ToolProvider } from "./types"; +import { InstallableIntegration } from "./types"; // Schema for the /meta endpoint response const valtownMetaSchema = z.object({ @@ -165,7 +165,7 @@ const handleCall = async ( } }; -export const valtown: ToolProvider = { +export const valtown: InstallableIntegration = { name: "valtown", onActivate: async (clusterId: string, integrations: z.infer) => { const config = integrations.valtown; diff --git a/control-plane/src/modules/jobs/external.ts b/control-plane/src/modules/jobs/external.ts index 5ec861a6..fe878252 100644 --- a/control-plane/src/modules/jobs/external.ts +++ b/control-plane/src/modules/jobs/external.ts @@ -5,7 +5,7 @@ import { z } from "zod"; import { logger } from "../observability/logger"; import { getJob } from "./jobs"; import { externalServices } from "../integrations/constants"; -import { getIntegrations, getToolProvider } from "../integrations/integrations"; +import { getIntegrations, getInstallables } from "../integrations/integrations"; const externalCallConsumer = env.SQS_EXTERNAL_TOOL_CALL_QUEUE_URL ? Consumer.create({ @@ -70,5 +70,5 @@ async function handleExternalCall(message: BaseMessage) { return; } - await getToolProvider(zodResult.data.service).handleCall(call, integrations); + await getInstallables(zodResult.data.service).handleCall(call, integrations); } diff --git a/control-plane/src/utilities/env.ts b/control-plane/src/utilities/env.ts index df6afe2a..d62384ea 100644 --- a/control-plane/src/utilities/env.ts +++ b/control-plane/src/utilities/env.ts @@ -106,6 +106,8 @@ const envSchema = z "POSTHOG_API_KEY", "POSTHOG_HOST", "ANALYTICS_BUCKET_NAME", + "NANGO_SECRET_KEY", + "SLACK_SIGNING_SECRET", ]; for (const key of EE_REQUIRED) {