From 215df83f46160efa6fce8b5d0f383bb8b3290c9e Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Tue, 27 Jan 2026 22:49:51 -0800 Subject: [PATCH 1/8] WIP --- apps/api/package.json | 3 + apps/api/slack-app-manifest.json | 71 +++ .../src/app/api/agent/[transport]/route.ts | 27 +- .../api/integrations/slack/callback/route.ts | 147 ++++++ .../api/integrations/slack/connect/route.ts | 83 ++++ .../slack/events/process-assistant-message.ts | 126 ++++++ .../slack/events/process-entity-details.ts | 151 +++++++ .../slack/events/process-link-shared.ts | 119 +++++ .../slack/events/process-mention.ts | 119 +++++ .../api/integrations/slack/events/route.ts | 330 ++++++++++++++ .../slack/jobs/process-mention/route.ts | 58 +++ apps/api/src/env.ts | 3 + .../tools/tasks/create-task/create-task.ts | 45 +- apps/api/src/lib/mcp/tools/utils/index.ts | 2 +- .../src/lib/mcp/tools/utils/register-tool.ts | 60 ++- apps/api/src/lib/slack-agent/mcp-clients.ts | 106 +++++ apps/api/src/lib/slack-agent/run-agent.ts | 419 ++++++++++++++++++ apps/api/src/lib/slack-agent/slack-blocks.ts | 258 +++++++++++ apps/api/src/lib/slack-agent/slack-client.ts | 5 + apps/api/src/lib/slack-agent/work-objects.ts | 297 +++++++++++++ apps/desktop/src/main/index.ts | 33 +- apps/desktop/src/renderer/index.tsx | 8 + apps/web/src/app/task/[slug]/page.tsx | 43 ++ bun.lock | 33 +- .../db/drizzle/0015_slack_integration.sql | 1 + packages/db/drizzle/meta/0015_snapshot.json | 20 +- packages/db/drizzle/meta/_journal.json | 4 +- packages/db/src/schema/enums.ts | 2 +- packages/db/src/schema/types.ts | 8 +- .../src/router/integration/integration.ts | 2 + .../src/router/integration/slack/index.ts | 6 + .../src/router/integration/slack/slack.ts | 103 +++++ .../src/router/integration/slack/utils.ts | 42 ++ 33 files changed, 2670 insertions(+), 64 deletions(-) create mode 100644 apps/api/slack-app-manifest.json create mode 100644 apps/api/src/app/api/integrations/slack/callback/route.ts create mode 100644 apps/api/src/app/api/integrations/slack/connect/route.ts create mode 100644 apps/api/src/app/api/integrations/slack/events/process-assistant-message.ts create mode 100644 apps/api/src/app/api/integrations/slack/events/process-entity-details.ts create mode 100644 apps/api/src/app/api/integrations/slack/events/process-link-shared.ts create mode 100644 apps/api/src/app/api/integrations/slack/events/process-mention.ts create mode 100644 apps/api/src/app/api/integrations/slack/events/route.ts create mode 100644 apps/api/src/app/api/integrations/slack/jobs/process-mention/route.ts create mode 100644 apps/api/src/lib/slack-agent/mcp-clients.ts create mode 100644 apps/api/src/lib/slack-agent/run-agent.ts create mode 100644 apps/api/src/lib/slack-agent/slack-blocks.ts create mode 100644 apps/api/src/lib/slack-agent/slack-client.ts create mode 100644 apps/api/src/lib/slack-agent/work-objects.ts create mode 100644 apps/web/src/app/task/[slug]/page.tsx create mode 100644 packages/db/drizzle/0015_slack_integration.sql create mode 100644 packages/trpc/src/router/integration/slack/index.ts create mode 100644 packages/trpc/src/router/integration/slack/slack.ts create mode 100644 packages/trpc/src/router/integration/slack/utils.ts diff --git a/apps/api/package.json b/apps/api/package.json index ad0a03f780c..c9ce54309f9 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -11,6 +11,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@anthropic-ai/sdk": "^0.71.2", "@electric-sql/client": "https://pkg.pr.new/@electric-sql/client@3724", "@linear/sdk": "^68.1.0", "@modelcontextprotocol/sdk": "^1.25.3", @@ -18,6 +19,8 @@ "@octokit/rest": "^22.0.1", "@octokit/webhooks": "^14.2.0", "@sentry/nextjs": "^10.36.0", + "@slack/types": "^2.19.0", + "@slack/web-api": "^7.13.0", "@superset/auth": "workspace:*", "@superset/db": "workspace:*", "@superset/shared": "workspace:*", diff --git a/apps/api/slack-app-manifest.json b/apps/api/slack-app-manifest.json new file mode 100644 index 00000000000..de731413dc2 --- /dev/null +++ b/apps/api/slack-app-manifest.json @@ -0,0 +1,71 @@ +{ + "display_information": { + "name": "Superset", + "description": "Superset's supercharged AI coding assistant - Spawn cloud agents, plan tasks, do code reviews, and more", + "background_color": "#0a0a0a", + "long_description": "Supercharge your coding workflows with Superset, an AI Coding agent. Spin up a cloud sandbox, organize your work, open worktrees on your desktop app, or get fast answers. Just start a DM with Superset for instant access without leaving Slack.\r\nSuperset can make mistakes. Please take care with prompts." + }, + "features": { + "app_home": { + "home_tab_enabled": true, + "messages_tab_enabled": true, + "messages_tab_read_only_enabled": false + }, + "bot_user": { + "display_name": "Superset", + "always_online": true + }, + "unfurl_domains": ["app.superset.sh"], + "assistant_view": { + "assistant_description": "Superset's AI Coding Agent - spin up sandboxes, plan out work, run agents and more", + "suggested_prompts": [ + { + "title": "Create Task", + "message": "Help me create a task for learning about how the Superset Slack agent works" + } + ] + }, + "rich_previews": { + "is_active": true, + "entity_types": ["slack#/entities/task"] + } + }, + "oauth_config": { + "redirect_urls": [ + "https://a9be19c152b9.ngrok-free.app/api/integrations/slack/callback" + ], + "scopes": { + "bot": [ + "app_mentions:read", + "chat:write", + "reactions:write", + "channels:history", + "groups:history", + "im:history", + "im:read", + "im:write", + "mpim:history", + "users:read", + "assistant:write", + "links:read", + "links:write" + ] + } + }, + "settings": { + "event_subscriptions": { + "request_url": "https://a9be19c152b9.ngrok-free.app/api/integrations/slack/events", + "bot_events": [ + "app_mention", + "message.im", + "assistant_thread_started", + "assistant_thread_context_changed", + "link_shared", + "entity_details_requested" + ] + }, + "org_deploy_enabled": false, + "socket_mode_enabled": false, + "token_rotation_enabled": false + } +} diff --git a/apps/api/src/app/api/agent/[transport]/route.ts b/apps/api/src/app/api/agent/[transport]/route.ts index da35f47e3da..a07d65462fb 100644 --- a/apps/api/src/app/api/agent/[transport]/route.ts +++ b/apps/api/src/app/api/agent/[transport]/route.ts @@ -5,7 +5,30 @@ import type { McpContext } from "@/lib/mcp/auth"; import { registerTools } from "@/lib/mcp/tools"; async function verifyToken(req: Request, bearerToken?: string) { - // 1. Try session auth + // 1. Try internal service auth (for Slack agent and other internal services) + const internalOrgId = req.headers.get("X-Internal-Organization-Id"); + const internalUserId = req.headers.get("X-Internal-User-Id"); + if (internalOrgId && internalUserId) { + // Internal requests are trusted when running in the same process + // This is used by the Slack agent to call MCP tools on behalf of users + console.log("[mcp/auth] Internal service auth:", { + organizationId: internalOrgId, + userId: internalUserId, + }); + return { + token: "internal", + clientId: "slack-agent", + scopes: ["mcp:full"], + extra: { + mcpContext: { + userId: internalUserId, + organizationId: internalOrgId, + } satisfies McpContext, + }, + }; + } + + // 2. Try session auth const session = await auth.api.getSession({ headers: req.headers }); if (session?.session) { const extendedSession = session.session as { @@ -28,7 +51,7 @@ async function verifyToken(req: Request, bearerToken?: string) { }; } - // 2. Try OAuth bearer token + // 3. Try OAuth bearer token if (bearerToken) { const mcpSession = await auth.api.getMcpSession({ headers: req.headers }); if (!mcpSession) return undefined; diff --git a/apps/api/src/app/api/integrations/slack/callback/route.ts b/apps/api/src/app/api/integrations/slack/callback/route.ts new file mode 100644 index 00000000000..b430b4d585e --- /dev/null +++ b/apps/api/src/app/api/integrations/slack/callback/route.ts @@ -0,0 +1,147 @@ +import { db } from "@superset/db/client"; +import type { SlackConfig } from "@superset/db/schema"; +import { integrationConnections, members } from "@superset/db/schema"; +import { and, eq } from "drizzle-orm"; + +import { env } from "@/env"; +import { verifySignedState } from "@/lib/oauth-state"; + +interface SlackOAuthResponse { + ok: boolean; + error?: string; + access_token: string; + token_type: string; + scope: string; + bot_user_id: string; + app_id: string; + team: { + id: string; + name: string; + }; + authed_user: { + id: string; + }; +} + +export async function GET(request: Request) { + const url = new URL(request.url); + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + const error = url.searchParams.get("error"); + + if (error) { + return Response.redirect( + `${env.NEXT_PUBLIC_WEB_URL}/integrations/slack?error=oauth_denied`, + ); + } + + if (!code || !state) { + return Response.redirect( + `${env.NEXT_PUBLIC_WEB_URL}/integrations/slack?error=missing_params`, + ); + } + + // Verify signed state (prevents forgery) + const stateData = verifySignedState(state); + if (!stateData) { + return Response.redirect( + `${env.NEXT_PUBLIC_WEB_URL}/integrations/slack?error=invalid_state`, + ); + } + + const { organizationId, userId } = stateData; + + // Re-verify membership at callback time (defense-in-depth) + const membership = await db.query.members.findFirst({ + where: and( + eq(members.organizationId, organizationId), + eq(members.userId, userId), + ), + }); + + if (!membership) { + console.error("[slack/callback] Membership verification failed:", { + organizationId, + userId, + }); + return Response.redirect( + `${env.NEXT_PUBLIC_WEB_URL}/integrations/slack?error=unauthorized`, + ); + } + + // Exchange code for token + // Use ngrok URL in dev for redirect_uri (must match connect route) + const isDev = env.NODE_ENV === "development"; + const redirectUri = isDev + ? "https://6b3ce1c0b374.ngrok-free.app/api/integrations/slack/callback" + : `${env.NEXT_PUBLIC_API_URL}/api/integrations/slack/callback`; + + const tokenResponse = await fetch("https://slack.com/api/oauth.v2.access", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: env.SLACK_CLIENT_ID, + client_secret: env.SLACK_CLIENT_SECRET, + redirect_uri: redirectUri, + code, + }), + }); + + if (!tokenResponse.ok) { + console.error("[slack/callback] Token exchange HTTP error:", { + status: tokenResponse.status, + }); + return Response.redirect( + `${env.NEXT_PUBLIC_WEB_URL}/integrations/slack?error=token_exchange_failed`, + ); + } + + const tokenData: SlackOAuthResponse = await tokenResponse.json(); + + if (!tokenData.ok) { + console.error("[slack/callback] Slack API error:", tokenData.error); + return Response.redirect( + `${env.NEXT_PUBLIC_WEB_URL}/integrations/slack?error=slack_api_error`, + ); + } + + const config: SlackConfig = { + provider: "slack", + botUserId: tokenData.bot_user_id, + }; + + // Slack bot tokens don't expire, so no tokenExpiresAt + await db + .insert(integrationConnections) + .values({ + organizationId, + connectedByUserId: userId, + provider: "slack", + accessToken: tokenData.access_token, + externalOrgId: tokenData.team.id, + externalOrgName: tokenData.team.name, + config, + }) + .onConflictDoUpdate({ + target: [ + integrationConnections.organizationId, + integrationConnections.provider, + ], + set: { + accessToken: tokenData.access_token, + externalOrgId: tokenData.team.id, + externalOrgName: tokenData.team.name, + connectedByUserId: userId, + config, + updatedAt: new Date(), + }, + }); + + console.log("[slack/callback] Connected workspace:", { + organizationId, + teamId: tokenData.team.id, + teamName: tokenData.team.name, + }); + + return Response.redirect(`${env.NEXT_PUBLIC_WEB_URL}/integrations/slack`); +} diff --git a/apps/api/src/app/api/integrations/slack/connect/route.ts b/apps/api/src/app/api/integrations/slack/connect/route.ts new file mode 100644 index 00000000000..863dd8827cf --- /dev/null +++ b/apps/api/src/app/api/integrations/slack/connect/route.ts @@ -0,0 +1,83 @@ +import { auth } from "@superset/auth/server"; +import { db } from "@superset/db/client"; +import { members } from "@superset/db/schema"; +import { and, eq } from "drizzle-orm"; + +import { env } from "@/env"; +import { createSignedState } from "@/lib/oauth-state"; + +const SLACK_SCOPES = [ + // Core bot functionality + "app_mentions:read", + "chat:write", + "reactions:write", + // Read messages for context + "channels:history", + "groups:history", + "im:history", + "mpim:history", + // User info for mapping + "users:read", +].join(","); + +export async function GET(request: Request) { + const url = new URL(request.url); + const organizationId = url.searchParams.get("organizationId"); + const isDev = env.NODE_ENV === "development"; + + if (!organizationId) { + return Response.json( + { error: "Missing organizationId parameter" }, + { status: 400 }, + ); + } + + let userId: string; + + // In dev, allow passing userId directly (for ngrok testing where cookies don't work) + const devUserId = url.searchParams.get("userId"); + if (isDev && devUserId) { + userId = devUserId; + } else { + const session = await auth.api.getSession({ + headers: request.headers, + }); + + if (!session?.user) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + userId = session.user.id; + + const membership = await db.query.members.findFirst({ + where: and( + eq(members.organizationId, organizationId), + eq(members.userId, userId), + ), + }); + + if (!membership) { + return Response.json( + { error: "User is not a member of this organization" }, + { status: 403 }, + ); + } + } + + const state = createSignedState({ + organizationId, + userId, + }); + + // Use ngrok URL in dev for redirect_uri + const redirectUri = isDev + ? "https://6b3ce1c0b374.ngrok-free.app/api/integrations/slack/callback" + : `${env.NEXT_PUBLIC_API_URL}/api/integrations/slack/callback`; + + const slackAuthUrl = new URL("https://slack.com/oauth/v2/authorize"); + slackAuthUrl.searchParams.set("client_id", env.SLACK_CLIENT_ID); + slackAuthUrl.searchParams.set("redirect_uri", redirectUri); + slackAuthUrl.searchParams.set("scope", SLACK_SCOPES); + slackAuthUrl.searchParams.set("state", state); + + return Response.redirect(slackAuthUrl.toString()); +} diff --git a/apps/api/src/app/api/integrations/slack/events/process-assistant-message.ts b/apps/api/src/app/api/integrations/slack/events/process-assistant-message.ts new file mode 100644 index 00000000000..dedefcf07c2 --- /dev/null +++ b/apps/api/src/app/api/integrations/slack/events/process-assistant-message.ts @@ -0,0 +1,126 @@ +import { db } from "@superset/db/client"; +import { integrationConnections } from "@superset/db/schema"; +import { and, eq } from "drizzle-orm"; + +import { runSlackAgent } from "@/lib/slack-agent/run-agent"; +import { formatActionsAsText } from "@/lib/slack-agent/slack-blocks"; +import { createSlackClient } from "@/lib/slack-agent/slack-client"; + +interface SlackMessageImEvent { + type: "message"; + channel_type: "im"; + user?: string; + text: string; + ts: string; + channel: string; + event_ts: string; + thread_ts?: string; + bot_id?: string; + subtype?: string; +} + +interface ProcessAssistantMessageParams { + event: SlackMessageImEvent; + teamId: string; + eventId: string; +} + +export async function processAssistantMessage({ + event, + teamId, + eventId, +}: ProcessAssistantMessageParams): Promise { + console.log("[slack/process-assistant-message] Processing message:", { + eventId, + teamId, + channel: event.channel, + user: event.user, + }); + + // Find connection by Slack team ID + const connection = await db.query.integrationConnections.findFirst({ + where: and( + eq(integrationConnections.provider, "slack"), + eq(integrationConnections.externalOrgId, teamId), + ), + }); + + if (!connection) { + console.error( + "[slack/process-assistant-message] No connection found for team:", + teamId, + ); + return; + } + + const slack = createSlackClient(connection.accessToken); + + // Use thread_ts if in a thread, otherwise use message ts + const threadTs = event.thread_ts ?? event.ts; + + // Set "thinking" status using assistant API + try { + await slack.assistant.threads.setStatus({ + channel_id: event.channel, + thread_ts: threadTs, + status: "Thinking...", + }); + } catch (err) { + console.warn( + "[slack/process-assistant-message] Failed to set status:", + err, + ); + } + + try { + // Run the AI agent + const result = await runSlackAgent({ + prompt: event.text, + channelId: event.channel, + threadTs, + organizationId: connection.organizationId, + slackToken: connection.accessToken, + slackTeamId: teamId, + }); + + // If we have actions, format them as text with URLs (enables unfurling) + // If no actions, use agent's text response + const hasActions = result.actions.length > 0; + const responseText = hasActions + ? formatActionsAsText(result.actions) + : result.text; + + await slack.chat.postMessage({ + channel: event.channel, + thread_ts: threadTs, + text: responseText, + }); + + console.log( + "[slack/process-assistant-message] Response posted successfully", + { + hasActions, + actionCount: result.actions.length, + }, + ); + } catch (err) { + console.error("[slack/process-assistant-message] Agent error:", err); + + await slack.chat.postMessage({ + channel: event.channel, + thread_ts: threadTs, + text: `Sorry, something went wrong: ${err instanceof Error ? err.message : "Unknown error"}`, + }); + } finally { + // Clear the status + try { + await slack.assistant.threads.setStatus({ + channel_id: event.channel, + thread_ts: threadTs, + status: "", + }); + } catch { + // Ignore errors clearing status + } + } +} diff --git a/apps/api/src/app/api/integrations/slack/events/process-entity-details.ts b/apps/api/src/app/api/integrations/slack/events/process-entity-details.ts new file mode 100644 index 00000000000..ee504bf9eb0 --- /dev/null +++ b/apps/api/src/app/api/integrations/slack/events/process-entity-details.ts @@ -0,0 +1,151 @@ +import { db } from "@superset/db/client"; +import { integrationConnections, tasks } from "@superset/db/schema"; +import { and, eq } from "drizzle-orm"; + +import { createSlackClient } from "@/lib/slack-agent/slack-client"; +import { + createTaskFlexpaneObject, + parseTaskSlugFromUrl, +} from "@/lib/slack-agent/work-objects"; + +interface SlackEntityDetailsRequestedEvent { + type: "entity_details_requested"; + user: string; + channel: string; + message_ts: string; + thread_ts?: string; + trigger_id: string; + user_locale: string; + entity_url: string; + app_unfurl_url: string; + external_ref: { + id: string; + type?: string; + }; + event_ts: string; +} + +interface ProcessEntityDetailsParams { + event: SlackEntityDetailsRequestedEvent; + teamId: string; + eventId: string; +} + +/** + * Handles the entity_details_requested event. + * + * This event fires when a user clicks on an unfurled Work Object to open + * the flexpane (side panel). We respond with entity.presentDetails to + * populate the flexpane with task details. + */ +export async function processEntityDetails({ + event, + teamId, + eventId, +}: ProcessEntityDetailsParams): Promise { + console.log("[slack/process-entity-details] Processing entity details:", { + eventId, + teamId, + entityUrl: event.entity_url, + externalRef: event.external_ref, + }); + + // Find connection by Slack team ID + const connection = await db.query.integrationConnections.findFirst({ + where: and( + eq(integrationConnections.provider, "slack"), + eq(integrationConnections.externalOrgId, teamId), + ), + }); + + if (!connection) { + console.error( + "[slack/process-entity-details] No connection found for team:", + teamId, + ); + return; + } + + const slack = createSlackClient(connection.accessToken); + + // Parse the task slug from the URL + const taskSlug = parseTaskSlugFromUrl(event.entity_url); + + if (!taskSlug) { + console.error( + "[slack/process-entity-details] Could not parse task slug from URL:", + event.entity_url, + ); + + // Respond with an error + try { + await slack.entity.presentDetails({ + trigger_id: event.trigger_id, + error: { + status: "not_found", + custom_message: "Could not find the requested task.", + }, + }); + } catch (err) { + console.error( + "[slack/process-entity-details] Failed to send error response:", + err, + ); + } + return; + } + + // Fetch the task from the database with full relations for flexpane + const task = await db.query.tasks.findFirst({ + where: and( + eq(tasks.organizationId, connection.organizationId), + eq(tasks.slug, taskSlug), + ), + with: { + status: true, + assignee: true, + creator: true, + organization: true, + }, + }); + + if (!task) { + console.error("[slack/process-entity-details] Task not found:", taskSlug); + + try { + await slack.entity.presentDetails({ + trigger_id: event.trigger_id, + error: { + status: "not_found", + custom_message: `Task "${taskSlug}" was not found.`, + }, + }); + } catch (err) { + console.error( + "[slack/process-entity-details] Failed to send error response:", + err, + ); + } + return; + } + + // Create the Work Object metadata for the flexpane + const entity = createTaskFlexpaneObject(task); + + try { + await slack.entity.presentDetails({ + trigger_id: event.trigger_id, + metadata: entity, + }); + + console.log( + "[slack/process-entity-details] Flexpane populated successfully for task:", + task.slug, + ); + } catch (err) { + console.error( + "[slack/process-entity-details] Failed to present details:", + err, + ); + } +} diff --git a/apps/api/src/app/api/integrations/slack/events/process-link-shared.ts b/apps/api/src/app/api/integrations/slack/events/process-link-shared.ts new file mode 100644 index 00000000000..54bef86f57b --- /dev/null +++ b/apps/api/src/app/api/integrations/slack/events/process-link-shared.ts @@ -0,0 +1,119 @@ +import type { EntityMetadata } from "@slack/types"; +import { db } from "@superset/db/client"; +import { integrationConnections, tasks } from "@superset/db/schema"; +import { and, eq } from "drizzle-orm"; + +import { createSlackClient } from "@/lib/slack-agent/slack-client"; +import { + createTaskWorkObject, + parseTaskSlugFromUrl, +} from "@/lib/slack-agent/work-objects"; + +interface SlackLinkSharedEvent { + type: "link_shared"; + user: string; + channel: string; + message_ts: string; + unfurl_id: string; + source: "conversations_history" | "composer"; + links: Array<{ + url: string; + domain: string; + }>; + event_ts: string; +} + +interface ProcessLinkSharedParams { + event: SlackLinkSharedEvent; + teamId: string; + eventId: string; +} + +export async function processLinkShared({ + event, + teamId, + eventId, +}: ProcessLinkSharedParams): Promise { + console.log("[slack/process-link-shared] Processing links:", { + eventId, + teamId, + linkCount: event.links.length, + }); + + // Find connection by Slack team ID + const connection = await db.query.integrationConnections.findFirst({ + where: and( + eq(integrationConnections.provider, "slack"), + eq(integrationConnections.externalOrgId, teamId), + ), + }); + + if (!connection) { + console.error( + "[slack/process-link-shared] No connection found for team:", + teamId, + ); + return; + } + + const slack = createSlackClient(connection.accessToken); + + // Build Work Object entities for each link + const entities: EntityMetadata[] = []; + + for (const link of event.links) { + const taskSlug = parseTaskSlugFromUrl(link.url); + if (!taskSlug) { + console.log( + "[slack/process-link-shared] Could not parse task slug from URL:", + link.url, + ); + continue; + } + + const task = await db.query.tasks.findFirst({ + where: and( + eq(tasks.organizationId, connection.organizationId), + eq(tasks.slug, taskSlug), + ), + with: { + status: true, + assignee: true, + creator: true, + }, + }); + + if (task) { + const entity = createTaskWorkObject(task); + // Ensure app_unfurl_url matches the exact URL from the message + entity.app_unfurl_url = link.url; + entities.push(entity); + console.log( + "[slack/process-link-shared] Built Work Object for task:", + task.slug, + ); + } else { + console.log("[slack/process-link-shared] Task not found:", taskSlug); + } + } + + // Send unfurls to Slack using Work Objects metadata format + if (entities.length > 0) { + try { + await slack.chat.unfurl({ + channel: event.channel, + ts: event.message_ts, + // Work Objects use metadata instead of unfurls + metadata: { + entities, + }, + }); + + console.log( + "[slack/process-link-shared] Work Objects unfurls sent successfully", + ); + } catch (err) { + console.error("[slack/process-link-shared] Failed to send unfurls:", err); + } + } +} diff --git a/apps/api/src/app/api/integrations/slack/events/process-mention.ts b/apps/api/src/app/api/integrations/slack/events/process-mention.ts new file mode 100644 index 00000000000..f27bec7be81 --- /dev/null +++ b/apps/api/src/app/api/integrations/slack/events/process-mention.ts @@ -0,0 +1,119 @@ +import { db } from "@superset/db/client"; +import { integrationConnections } from "@superset/db/schema"; +import { and, eq } from "drizzle-orm"; + +import { runSlackAgent } from "@/lib/slack-agent/run-agent"; +import { formatActionsAsText } from "@/lib/slack-agent/slack-blocks"; +import { createSlackClient } from "@/lib/slack-agent/slack-client"; + +interface SlackAppMentionEvent { + type: "app_mention"; + user: string; + text: string; + ts: string; + channel: string; + event_ts: string; + thread_ts?: string; +} + +interface ProcessMentionParams { + event: SlackAppMentionEvent; + teamId: string; + eventId: string; +} + +export async function processSlackMention({ + event, + teamId, + eventId, +}: ProcessMentionParams): Promise { + console.log("[slack/process-mention] Processing mention:", { + eventId, + teamId, + channel: event.channel, + user: event.user, + }); + + // Find connection by Slack team ID + const connection = await db.query.integrationConnections.findFirst({ + where: and( + eq(integrationConnections.provider, "slack"), + eq(integrationConnections.externalOrgId, teamId), + ), + }); + + if (!connection) { + console.error( + "[slack/process-mention] No connection found for team:", + teamId, + ); + return; + } + + const slack = createSlackClient(connection.accessToken); + + // React with eyes to show we're processing + try { + await slack.reactions.add({ + channel: event.channel, + timestamp: event.ts, + name: "eyes", + }); + } catch (err) { + console.warn("[slack/process-mention] Failed to add reaction:", err); + } + + // Determine the thread timestamp (reply in thread if it's a threaded message) + const threadTs = event.thread_ts ?? event.ts; + + try { + // Run the AI agent + const result = await runSlackAgent({ + prompt: event.text, + channelId: event.channel, + threadTs, + organizationId: connection.organizationId, + slackToken: connection.accessToken, + slackTeamId: teamId, + }); + + // If we have actions, format them as text with URLs (enables unfurling) + // If no actions, use agent's text response + const hasActions = result.actions.length > 0; + const responseText = hasActions + ? formatActionsAsText(result.actions) + : result.text; + + await slack.chat.postMessage({ + channel: event.channel, + thread_ts: threadTs, + text: responseText, + }); + + console.log("[slack/process-mention] Response posted successfully", { + hasActions, + actionCount: result.actions.length, + actionTypes: result.actions.map((a) => a.type), + }); + } catch (err) { + console.error("[slack/process-mention] Agent error:", err); + + // Post error message to the channel + await slack.chat.postMessage({ + channel: event.channel, + thread_ts: threadTs, + text: `Sorry, something went wrong: ${err instanceof Error ? err.message : "Unknown error"}`, + }); + } finally { + // Remove the eyes reaction + try { + await slack.reactions.remove({ + channel: event.channel, + timestamp: event.ts, + name: "eyes", + }); + } catch { + // Ignore errors removing reaction + } + } +} diff --git a/apps/api/src/app/api/integrations/slack/events/route.ts b/apps/api/src/app/api/integrations/slack/events/route.ts new file mode 100644 index 00000000000..f81e082d677 --- /dev/null +++ b/apps/api/src/app/api/integrations/slack/events/route.ts @@ -0,0 +1,330 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; +import { Client } from "@upstash/qstash"; + +import { env } from "@/env"; +import { processAssistantMessage } from "./process-assistant-message"; +import { processEntityDetails } from "./process-entity-details"; +import { processLinkShared } from "./process-link-shared"; +import { processSlackMention } from "./process-mention"; + +const qstash = new Client({ token: env.QSTASH_TOKEN }); +const isDev = env.NODE_ENV === "development"; + +interface SlackChallenge { + type: "url_verification"; + challenge: string; + token: string; +} + +interface SlackAppMentionEvent { + type: "app_mention"; + user: string; + text: string; + ts: string; + channel: string; + event_ts: string; + thread_ts?: string; +} + +interface SlackMessageImEvent { + type: "message"; + channel_type: "im"; + user?: string; + text: string; + ts: string; + channel: string; + event_ts: string; + thread_ts?: string; + bot_id?: string; + subtype?: string; +} + +interface SlackAssistantThreadStartedEvent { + type: "assistant_thread_started"; + assistant_thread: { + user_id: string; + context: { + channel_id: string; + team_id: string; + enterprise_id?: string; + }; + channel_id: string; + thread_ts: string; + }; + event_ts: string; +} + +interface SlackAssistantThreadContextChangedEvent { + type: "assistant_thread_context_changed"; + assistant_thread: { + user_id: string; + context: { + channel_id: string; + team_id: string; + enterprise_id?: string; + }; + channel_id: string; + thread_ts: string; + }; + event_ts: string; +} + +interface SlackLinkSharedEvent { + type: "link_shared"; + user: string; + channel: string; + message_ts: string; + unfurl_id: string; + source: "conversations_history" | "composer"; + links: Array<{ + url: string; + domain: string; + }>; + event_ts: string; +} + +interface SlackEntityDetailsRequestedEvent { + type: "entity_details_requested"; + user: string; + channel: string; + message_ts: string; + thread_ts?: string; + trigger_id: string; + user_locale: string; + entity_url: string; + app_unfurl_url: string; + external_ref: { + id: string; + type?: string; + }; + event_ts: string; +} + +type SlackEvent = + | SlackAppMentionEvent + | SlackMessageImEvent + | SlackAssistantThreadStartedEvent + | SlackAssistantThreadContextChangedEvent + | SlackLinkSharedEvent + | SlackEntityDetailsRequestedEvent; + +interface SlackEventPayload { + type: "event_callback"; + token: string; + team_id: string; + api_app_id: string; + event: SlackEvent; + event_id: string; + event_time: number; +} + +type SlackPayload = SlackChallenge | SlackEventPayload; + +function verifySlackSignature({ + body, + signature, + timestamp, +}: { + body: string; + signature: string; + timestamp: string; +}): boolean { + // Check timestamp to prevent replay attacks (5 minute window) + const timestampSec = Number.parseInt(timestamp, 10); + const now = Math.floor(Date.now() / 1000); + if (Math.abs(now - timestampSec) > 60 * 5) { + console.error("[slack/events] Timestamp too old or in future"); + return false; + } + + // Create signature base string and verify + const sigBase = `v0:${timestamp}:${body}`; + const mySignature = `v0=${createHmac("sha256", env.SLACK_SIGNING_SECRET).update(sigBase).digest("hex")}`; + + try { + return timingSafeEqual( + Buffer.from(mySignature, "utf8"), + Buffer.from(signature, "utf8"), + ); + } catch { + return false; + } +} + +export async function POST(request: Request) { + const body = await request.text(); + const signature = request.headers.get("x-slack-signature"); + const timestamp = request.headers.get("x-slack-request-timestamp"); + + if (!signature || !timestamp) { + return Response.json( + { error: "Missing signature headers" }, + { status: 401 }, + ); + } + + // Verify signature + if (!verifySlackSignature({ body, signature, timestamp })) { + console.error("[slack/events] Signature verification failed"); + return Response.json({ error: "Invalid signature" }, { status: 401 }); + } + + const payload: SlackPayload = JSON.parse(body); + + // Handle URL verification challenge (Slack sends this when setting up Events URL) + if (payload.type === "url_verification") { + return Response.json({ challenge: payload.challenge }); + } + + // Handle event callbacks + if (payload.type === "event_callback") { + const { event, team_id, event_id } = payload; + + // Handle app_mention events (channel @mentions) + if (event.type === "app_mention") { + console.log("[slack/events] Received app_mention:", { + eventId: event_id, + teamId: team_id, + channel: event.channel, + user: event.user, + }); + + // Process async (Slack requires response within 3s) + // In dev, call directly since QStash can't reach localhost + // In prod, queue via QStash for reliability + if (isDev) { + // Fire and forget - don't await + processSlackMention({ + event, + teamId: team_id, + eventId: event_id, + }).catch((error) => { + console.error("[slack/events] Process mention error:", error); + }); + } else { + try { + await qstash.publishJSON({ + url: `${env.NEXT_PUBLIC_API_URL}/api/integrations/slack/jobs/process-mention`, + body: { + event, + teamId: team_id, + eventId: event_id, + }, + retries: 3, + }); + } catch (error) { + console.error("[slack/events] Failed to queue mention job:", error); + } + } + } + + // Handle message.im events (DMs to the bot, including agent messages) + if ( + event.type === "message" && + "channel_type" in event && + event.channel_type === "im" + ) { + // Skip bot messages to prevent infinite loops + if (event.bot_id || event.subtype === "bot_message" || !event.user) { + console.log("[slack/events] Skipping bot message"); + return new Response("ok", { status: 200 }); + } + + console.log("[slack/events] Received message.im:", { + eventId: event_id, + teamId: team_id, + channel: event.channel, + user: event.user, + }); + + if (isDev) { + processAssistantMessage({ + event, + teamId: team_id, + eventId: event_id, + }).catch((err: unknown) => { + console.error("[slack/events] Process assistant message error:", err); + }); + } else { + try { + await qstash.publishJSON({ + url: `${env.NEXT_PUBLIC_API_URL}/api/integrations/slack/jobs/process-assistant-message`, + body: { + event, + teamId: team_id, + eventId: event_id, + }, + retries: 3, + }); + } catch (error) { + console.error( + "[slack/events] Failed to queue assistant message job:", + error, + ); + } + } + } + + // Handle assistant_thread_started (user opens agent chat) + if (event.type === "assistant_thread_started") { + console.log("[slack/events] Received assistant_thread_started:", { + eventId: event_id, + teamId: team_id, + userId: event.assistant_thread.user_id, + channelId: event.assistant_thread.channel_id, + }); + // Optional: Set initial status or prompts here + } + + // Handle assistant_thread_context_changed (user switches channels) + if (event.type === "assistant_thread_context_changed") { + console.log("[slack/events] Received assistant_thread_context_changed:", { + eventId: event_id, + teamId: team_id, + contextChannelId: event.assistant_thread.context.channel_id, + }); + // Optional: Update context-aware suggestions + } + + // Handle link_shared events (URL unfurling) + if (event.type === "link_shared") { + console.log("[slack/events] Received link_shared:", { + eventId: event_id, + teamId: team_id, + channel: event.channel, + links: event.links, + }); + + // Process synchronously since unfurling needs quick response + processLinkShared({ + event, + teamId: team_id, + eventId: event_id, + }).catch((err: unknown) => { + console.error("[slack/events] Process link shared error:", err); + }); + } + + // Handle entity_details_requested events (Work Object flexpane) + if (event.type === "entity_details_requested") { + console.log("[slack/events] Received entity_details_requested:", { + eventId: event_id, + teamId: team_id, + entityUrl: event.entity_url, + externalRef: event.external_ref, + }); + + // Process synchronously since flexpane needs quick response + processEntityDetails({ + event, + teamId: team_id, + eventId: event_id, + }).catch((err: unknown) => { + console.error("[slack/events] Process entity details error:", err); + }); + } + } + + // Always return 200 OK to Slack quickly + return new Response("ok", { status: 200 }); +} diff --git a/apps/api/src/app/api/integrations/slack/jobs/process-mention/route.ts b/apps/api/src/app/api/integrations/slack/jobs/process-mention/route.ts new file mode 100644 index 00000000000..70d1f6f7c01 --- /dev/null +++ b/apps/api/src/app/api/integrations/slack/jobs/process-mention/route.ts @@ -0,0 +1,58 @@ +import { Receiver } from "@upstash/qstash"; +import { z } from "zod"; + +import { env } from "@/env"; +import { processSlackMention } from "../../events/process-mention"; + +const receiver = new Receiver({ + currentSigningKey: env.QSTASH_CURRENT_SIGNING_KEY, + nextSigningKey: env.QSTASH_NEXT_SIGNING_KEY, +}); + +const payloadSchema = z.object({ + event: z.object({ + type: z.literal("app_mention"), + user: z.string(), + text: z.string(), + ts: z.string(), + channel: z.string(), + event_ts: z.string(), + thread_ts: z.string().optional(), + }), + teamId: z.string(), + eventId: z.string(), +}); + +export async function POST(request: Request) { + const body = await request.text(); + const signature = request.headers.get("upstash-signature"); + + // Skip signature verification in development + const isDev = env.NODE_ENV === "development"; + + if (!isDev) { + if (!signature) { + return Response.json({ error: "Missing signature" }, { status: 401 }); + } + + const isValid = await receiver.verify({ + body, + signature, + url: `${env.NEXT_PUBLIC_API_URL}/api/integrations/slack/jobs/process-mention`, + }); + + if (!isValid) { + return Response.json({ error: "Invalid signature" }, { status: 401 }); + } + } + + const parsed = payloadSchema.safeParse(JSON.parse(body)); + if (!parsed.success) { + console.error("[slack/process-mention] Invalid payload:", parsed.error); + return Response.json({ error: "Invalid payload" }, { status: 400 }); + } + + await processSlackMention(parsed.data); + + return Response.json({ success: true }); +} diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index b83d7c741a8..915c906c8d2 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -24,6 +24,9 @@ export const env = createEnv({ GH_APP_ID: z.string().min(1), GH_APP_PRIVATE_KEY: z.string().min(1), GH_WEBHOOK_SECRET: z.string().min(1), + SLACK_CLIENT_ID: z.string().min(1), + SLACK_CLIENT_SECRET: z.string().min(1), + SLACK_SIGNING_SECRET: z.string().min(1), QSTASH_TOKEN: z.string().min(1), QSTASH_CURRENT_SIGNING_KEY: z.string().min(1), QSTASH_NEXT_SIGNING_KEY: z.string().min(1), diff --git a/apps/api/src/lib/mcp/tools/tasks/create-task/create-task.ts b/apps/api/src/lib/mcp/tools/tasks/create-task/create-task.ts index 2baab19d416..b9e32e83a94 100644 --- a/apps/api/src/lib/mcp/tools/tasks/create-task/create-task.ts +++ b/apps/api/src/lib/mcp/tools/tasks/create-task/create-task.ts @@ -1,9 +1,8 @@ import { db, dbWs } from "@superset/db/client"; import { taskStatuses, tasks } from "@superset/db/schema"; -import { getCurrentTxid } from "@superset/db/utils"; import { and, eq, ilike, or } from "drizzle-orm"; import { z } from "zod"; -import { registerTool } from "../../utils"; +import { registerTool, toolError, toolResult } from "../../utils"; const PRIORITIES = ["urgent", "high", "medium", "low", "none"] as const; type TaskPriority = (typeof PRIORITIES)[number]; @@ -59,6 +58,17 @@ function generateUniqueSlug( return slug; } +// Output schema for type-safe structured content +const createTaskOutputSchema = { + created: z.array( + z.object({ + id: z.string(), + slug: z.string(), + title: z.string(), + }), + ), +}; + export const register = registerTool( "create_task", { @@ -70,6 +80,7 @@ export const register = registerTool( .max(25) .describe("Array of tasks to create (1-25)"), }, + outputSchema: createTaskOutputSchema, }, async (params, ctx) => { const taskInputs = params.tasks as TaskInput[]; @@ -93,10 +104,7 @@ export const register = registerTool( defaultStatusId = defaultStatus?.id; if (!defaultStatusId) { - return { - content: [{ type: "text", text: "Error: No default status found" }], - isError: true, - }; + return toolError("No default status found"); } } @@ -168,30 +176,15 @@ export const register = registerTool( } // Insert all tasks in a single transaction - const result = await dbWs.transaction(async (tx) => { - const createdTasks = await tx + const createdTasks = await dbWs.transaction(async (tx) => { + return tx .insert(tasks) .values(taskValues) .returning({ id: tasks.id, slug: tasks.slug, title: tasks.title }); - - const txid = await getCurrentTxid(tx); - return { createdTasks, txid }; }); - return { - content: [ - { - type: "text", - text: JSON.stringify( - { - created: result.createdTasks, - txid: result.txid, - }, - null, - 2, - ), - }, - ], - }; + return toolResult({ + created: createdTasks, + }); }, ); diff --git a/apps/api/src/lib/mcp/tools/utils/index.ts b/apps/api/src/lib/mcp/tools/utils/index.ts index 203be954c35..6e8d4ff7e9e 100644 --- a/apps/api/src/lib/mcp/tools/utils/index.ts +++ b/apps/api/src/lib/mcp/tools/utils/index.ts @@ -2,4 +2,4 @@ export { DEVICE_ONLINE_THRESHOLD_MS, executeOnDevice, } from "./execute-on-device"; -export { registerTool } from "./register-tool"; +export { registerTool, toolError, toolResult } from "./register-tool"; diff --git a/apps/api/src/lib/mcp/tools/utils/register-tool.ts b/apps/api/src/lib/mcp/tools/utils/register-tool.ts index 2bbcd997383..95f8dfad061 100644 --- a/apps/api/src/lib/mcp/tools/utils/register-tool.ts +++ b/apps/api/src/lib/mcp/tools/utils/register-tool.ts @@ -2,41 +2,73 @@ import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; import type { + CallToolResult, ServerNotification, ServerRequest, } from "@modelcontextprotocol/sdk/types.js"; import type { McpContext } from "../../auth"; -type ToolResult = { - content: Array<{ type: "text"; text: string }>; - isError?: boolean; -}; - type ToolExtra = RequestHandlerExtra & { authInfo?: AuthInfo & { extra?: { mcpContext?: McpContext } }; }; -type InputSchema = Record; +// biome-ignore lint/suspicious/noExplicitAny: Zod schemas vary +type ZodShape = Record; + +/** + * Helper to create a successful tool result with structured content. + * Per MCP spec, returns both text (backwards compat) and structuredContent (typed). + */ +export function toolResult>( + data: T, +): CallToolResult & { structuredContent: T } { + return { + content: [{ type: "text", text: JSON.stringify(data, null, 2) }], + structuredContent: data, + }; +} +/** + * Helper to create an error tool result. + */ +export function toolError(message: string): CallToolResult { + return { + content: [{ type: "text", text: `Error: ${message}` }], + isError: true, + }; +} + +/** + * Register a typesafe MCP tool. + * Pass Zod schemas for inputSchema and outputSchema. + * Use toolResult() helper to return typed structuredContent. + */ export function registerTool( name: string, - config: { description: string; inputSchema: InputSchema }, + config: { + description: string; + inputSchema: ZodShape; + outputSchema?: ZodShape; + }, handler: ( params: Record, ctx: McpContext, - ) => Promise, + ) => Promise, ) { return (server: McpServer) => { - server.tool( + server.registerTool( name, - config.description, - config.inputSchema, - async (params: Record, extra: ToolExtra) => { - const ctx = extra.authInfo?.extra?.mcpContext; + { + description: config.description, + inputSchema: config.inputSchema, + outputSchema: config.outputSchema, + }, + async (params, extra) => { + const ctx = (extra as ToolExtra).authInfo?.extra?.mcpContext; if (!ctx) { throw new Error("No MCP context available - authentication required"); } - return handler(params, ctx); + return handler(params as Record, ctx); }, ); }; diff --git a/apps/api/src/lib/slack-agent/mcp-clients.ts b/apps/api/src/lib/slack-agent/mcp-clients.ts new file mode 100644 index 00000000000..38ee6ae3153 --- /dev/null +++ b/apps/api/src/lib/slack-agent/mcp-clients.ts @@ -0,0 +1,106 @@ +import type Anthropic from "@anthropic-ai/sdk"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; + +import { env } from "@/env"; + +interface McpTool { + name: string; + description?: string; + inputSchema: unknown; +} + +/** + * Creates an MCP client connected to the Superset MCP server via HTTP. + * Uses a service-level authentication bypass for internal agent calls. + */ +export async function createSupersetMcpClient({ + organizationId, + userId, +}: { + organizationId: string; + userId: string; +}): Promise { + const transport = new StreamableHTTPClientTransport( + new URL(`${env.NEXT_PUBLIC_API_URL}/api/agent/mcp`), + { + requestInit: { + headers: { + // Pass context as headers for internal service calls + "X-Internal-Organization-Id": organizationId, + "X-Internal-User-Id": userId, + }, + }, + }, + ); + + const client = new Client({ + name: "slack-agent-superset", + version: "1.0.0", + }); + + await client.connect(transport); + return client; +} + +/** + * Creates an MCP client for Slack by spawning the official Slack MCP server. + * Uses the @modelcontextprotocol/server-slack package via npx. + */ +export async function createSlackMcpClient({ + token, + teamId, +}: { + token: string; + teamId: string; +}): Promise { + const transport = new StdioClientTransport({ + command: "npx", + args: ["-y", "@modelcontextprotocol/server-slack"], + env: { + ...process.env, + SLACK_BOT_TOKEN: token, + SLACK_TEAM_ID: teamId, + }, + }); + + const client = new Client({ + name: "slack-agent-slack", + version: "1.0.0", + }); + + await client.connect(transport); + return client; +} + +/** + * Converts an MCP tool definition to the Anthropic API tool format. + * Prefixes tool names with the source (superset_ or slack_) for disambiguation. + */ +export function mcpToolToAnthropicTool( + tool: McpTool, + prefix: string, +): Anthropic.Tool { + return { + name: `${prefix}_${tool.name}`, + description: tool.description ?? "", + input_schema: tool.inputSchema as Anthropic.Tool.InputSchema, + }; +} + +/** + * Parses a prefixed tool name back to the original name and source. + */ +export function parseToolName(prefixedName: string): { + prefix: string; + toolName: string; +} { + const underscoreIndex = prefixedName.indexOf("_"); + if (underscoreIndex === -1) { + return { prefix: prefixedName, toolName: "" }; + } + const prefix = prefixedName.slice(0, underscoreIndex); + const toolName = prefixedName.slice(underscoreIndex + 1); + return { prefix, toolName }; +} diff --git a/apps/api/src/lib/slack-agent/run-agent.ts b/apps/api/src/lib/slack-agent/run-agent.ts new file mode 100644 index 00000000000..c9f985fd0ee --- /dev/null +++ b/apps/api/src/lib/slack-agent/run-agent.ts @@ -0,0 +1,419 @@ +import Anthropic from "@anthropic-ai/sdk"; +import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { WebClient } from "@slack/web-api"; + +import { db } from "@superset/db/client"; +import { integrationConnections } from "@superset/db/schema"; +import { and, eq } from "drizzle-orm"; + +import { + createSlackMcpClient, + createSupersetMcpClient, + mcpToolToAnthropicTool, + parseToolName, +} from "./mcp-clients"; +import type { AgentAction } from "./slack-blocks"; + +/** + * Fetches thread context (previous messages) for the agent. + * Returns formatted string of thread messages. + */ +async function fetchThreadContext({ + token, + channelId, + threadTs, + limit = 20, +}: { + token: string; + channelId: string; + threadTs: string; + limit?: number; +}): Promise { + try { + const slack = new WebClient(token); + const result = await slack.conversations.replies({ + channel: channelId, + ts: threadTs, + limit, + }); + + if (!result.messages || result.messages.length === 0) { + return ""; + } + + // Format messages, excluding the current mention (last message) + const messages = result.messages.slice(0, -1); + if (messages.length === 0) { + return ""; + } + + const formatted = messages + .map((msg) => `<${msg.user}>: ${msg.text}`) + .join("\n"); + + return `--- Thread Context (${messages.length} previous messages) ---\n${formatted}\n--- End Thread Context ---`; + } catch (error) { + console.warn("[slack-agent] Failed to fetch thread context:", error); + return ""; + } +} + +interface RunSlackAgentParams { + prompt: string; + channelId: string; + threadTs: string; + organizationId: string; + slackToken: string; + slackTeamId: string; +} + +export interface SlackAgentResult { + text: string; + actions: AgentAction[]; +} + +/** + * Extracts action data from MCP tool result for rich Slack formatting. + * Uses structuredContent if available (type-safe), falls back to parsing text content. + */ +function getActionFromToolResult( + toolName: string, + // biome-ignore lint/suspicious/noExplicitAny: MCP result varies by tool + result: any, +): AgentAction | null { + // Prefer structuredContent (typed) over parsing text + const data = result.structuredContent ?? parseTextContent(result.content); + if (!data) return null; + + // Map tool results to actions + if (toolName === "create_task" && data.created) { + return { + type: "task_created", + tasks: data.created.map( + (t: { id: string; slug: string; title: string }) => ({ + id: t.id, + slug: t.slug, + title: t.title, + status: "Backlog", + }), + ), + }; + } + + if (toolName === "update_task" && data.updated) { + return { + type: "task_updated", + tasks: data.updated.map( + (t: { id: string; slug: string; title: string }) => ({ + id: t.id, + slug: t.slug, + title: t.title, + }), + ), + }; + } + + // Workspace actions - desktop returns { workspaceId, workspaceName, branch } + if (toolName === "create_workspace" && data.workspaceId) { + return { + type: "workspace_created", + workspaces: [ + { + id: data.workspaceId, + name: data.workspaceName, + branch: data.branch, + }, + ], + }; + } + + if ( + (toolName === "switch_workspace" || toolName === "navigate_to_workspace") && + data.workspaceId + ) { + return { + type: "workspace_switched", + workspaces: [ + { + id: data.workspaceId, + name: data.workspaceName, + branch: data.branch, + }, + ], + }; + } + + return null; +} + +/** + * Fallback: parse JSON from text content (for tools without structuredContent) + */ +// biome-ignore lint/suspicious/noExplicitAny: MCP content is loosely typed +function parseTextContent(content: any): Record | null { + try { + const contentItem = content?.[0]; + if ( + !contentItem || + typeof contentItem !== "object" || + !("text" in contentItem) + ) { + return null; + } + return JSON.parse(contentItem.text as string); + } catch { + return null; + } +} + +// Denylist of Superset MCP tools to exclude from Slack agent +// These are desktop-only navigation tools that don't make sense in Slack context +const DENIED_SUPERSET_TOOLS = new Set([ + "navigate_to_workspace", // Desktop navigation only + "switch_workspace", // Desktop navigation only + "get_app_context", // Desktop app state +]); + +const SYSTEM_PROMPT = `You are a helpful assistant in Slack for Superset, a task management application. + +You can: +- Create, update, search, and manage tasks using superset_* tools +- Read Slack messages and context using slack_* tools +- Help users understand conversations and create actionable items from discussions + +Guidelines: +- Be concise and clear (this is Slack, not email) +- When creating tasks, extract key details from the conversation +- Use Slack formatting: *bold*, _italic*, \`code\`, > quotes +- If an action fails, explain what went wrong and suggest alternatives + +Context gathering: +- If the user's request references something you don't have context for (a person, a conversation, a decision, etc.), USE THE SLACK TOOLS to find it +- Use slack_search_messages to find relevant discussions by keyword +- Use slack_get_channel_history to read recent channel messages +- Use slack_get_thread_replies to get full thread context +- Use slack_get_users to look up user details when names are mentioned +- Don't ask the user for context you can find yourself - be proactive + +Available tool prefixes: +- superset_*: Task management tools (create_task, list_tasks, update_task, etc.) +- slack_*: Slack tools (get_channel_history, search_messages, get_thread, etc.)`; + +export async function runSlackAgent( + params: RunSlackAgentParams, +): Promise { + const anthropic = new Anthropic(); + const actions: AgentAction[] = []; + + // Get the connectedByUserId to use for internal auth + const connection = await db.query.integrationConnections.findFirst({ + where: and( + eq(integrationConnections.organizationId, params.organizationId), + eq(integrationConnections.provider, "slack"), + ), + columns: { connectedByUserId: true }, + }); + + if (!connection) { + throw new Error("Slack connection not found"); + } + + let supersetMcp: Client | null = null; + let slackMcp: Client | null = null; + + try { + // Fetch thread context and create MCP clients in parallel + console.log( + "[slack-agent] Fetching thread context and creating MCP clients...", + ); + + const [threadContext, supersetMcpResult, slackMcpResult] = + await Promise.all([ + fetchThreadContext({ + token: params.slackToken, + channelId: params.channelId, + threadTs: params.threadTs, + }), + createSupersetMcpClient({ + organizationId: params.organizationId, + userId: connection.connectedByUserId, + }), + createSlackMcpClient({ + token: params.slackToken, + teamId: params.slackTeamId, + }), + ]); + + supersetMcp = supersetMcpResult; + slackMcp = slackMcpResult; + + if (threadContext) { + console.log("[slack-agent] Thread context fetched"); + } + + // List available tools from both MCPs + const [supersetToolsResult, slackToolsResult] = await Promise.all([ + supersetMcp.listTools(), + slackMcp.listTools(), + ]); + + // Convert MCP tools to Anthropic tool format with prefixes + const supersetTools = supersetToolsResult.tools + .map((t) => mcpToolToAnthropicTool(t, "superset")) + .filter((t) => !DENIED_SUPERSET_TOOLS.has(t.name)); + + const slackTools = slackToolsResult.tools.map((t) => + mcpToolToAnthropicTool(t, "slack"), + ); + + const tools: Anthropic.Tool[] = [...supersetTools, ...slackTools]; + + console.log( + "[slack-agent] Available tools:", + tools.map((t) => t.name), + ); + + // Build context-aware system prompt + const contextualSystem = `${SYSTEM_PROMPT} + +Current context: +- Slack Channel: ${params.channelId} +- Thread: ${params.threadTs} +- Organization ID: ${params.organizationId}`; + + // Initialize conversation with thread context if available + const userContent = threadContext + ? `${threadContext}\n\nCurrent message:\n${params.prompt}` + : params.prompt; + + const messages: Anthropic.MessageParam[] = [ + { + role: "user", + content: userContent, + }, + ]; + + // Agent loop + let response = await anthropic.messages.create({ + model: "claude-sonnet-4-5", + max_tokens: 2048, + system: contextualSystem, + tools, + messages, + }); + + // Process tool calls in a loop until we get a final response + const MAX_TOOL_ITERATIONS = 10; + let iterations = 0; + + while ( + response.stop_reason === "tool_use" && + iterations < MAX_TOOL_ITERATIONS + ) { + iterations++; + const toolUseBlocks = response.content.filter( + (b): b is Anthropic.ToolUseBlock => b.type === "tool_use", + ); + + const toolResults: Anthropic.ToolResultBlockParam[] = []; + + for (const toolUse of toolUseBlocks) { + console.log("[slack-agent] Executing tool:", toolUse.name); + + const { prefix, toolName } = parseToolName(toolUse.name); + const mcp = prefix === "superset" ? supersetMcp : slackMcp; + + if (!mcp) { + toolResults.push({ + type: "tool_result", + tool_use_id: toolUse.id, + content: JSON.stringify({ + error: `Unknown tool prefix: ${prefix}`, + }), + is_error: true, + }); + continue; + } + + try { + const result = await mcp.callTool({ + name: toolName, + arguments: toolUse.input as Record, + }); + + const resultContent = JSON.stringify(result.content); + + // Track Superset actions for rich formatting + if (prefix === "superset") { + const action = getActionFromToolResult(toolName, result); + if (action) { + actions.push(action); + } + } + + toolResults.push({ + type: "tool_result", + tool_use_id: toolUse.id, + content: resultContent, + }); + } catch (error) { + console.error( + "[slack-agent] Tool execution error:", + toolUse.name, + error, + ); + toolResults.push({ + type: "tool_result", + tool_use_id: toolUse.id, + content: JSON.stringify({ + error: + error instanceof Error + ? error.message + : "Tool execution failed", + }), + is_error: true, + }); + } + } + + // Add assistant response and tool results to conversation + messages.push({ role: "assistant", content: response.content }); + messages.push({ role: "user", content: toolResults }); + + // Continue the conversation + response = await anthropic.messages.create({ + model: "claude-sonnet-4-5", + max_tokens: 2048, + system: contextualSystem, + tools, + messages, + }); + } + + // Extract text response + const textBlock = response.content.find( + (b): b is Anthropic.TextBlock => b.type === "text", + ); + + return { + text: textBlock?.text ?? "Done!", + actions, + }; + } finally { + // Cleanup: close MCP clients + if (supersetMcp) { + try { + await supersetMcp.close(); + } catch { + // Ignore close errors + } + } + if (slackMcp) { + try { + await slackMcp.close(); + } catch { + // Ignore close errors + } + } + } +} diff --git a/apps/api/src/lib/slack-agent/slack-blocks.ts b/apps/api/src/lib/slack-agent/slack-blocks.ts new file mode 100644 index 00000000000..6854ed33a1b --- /dev/null +++ b/apps/api/src/lib/slack-agent/slack-blocks.ts @@ -0,0 +1,258 @@ +import type { KnownBlock, MessageAttachment } from "@slack/web-api"; + +import { env } from "@/env"; + +// Action types the agent can perform +export type AgentActionType = + | "task_created" + | "task_updated" + | "task_deleted" + | "workspace_created" + | "workspace_switched"; + +export interface TaskData { + id: string; + slug: string; + title: string; + description?: string | null; + status?: string; + priority?: string; +} + +export interface WorkspaceData { + id: string; + name: string; + branch?: string; +} + +export type AgentAction = + | { + type: "task_created" | "task_updated" | "task_deleted"; + tasks: TaskData[]; + } + | { + type: "workspace_created" | "workspace_switched"; + workspaces: WorkspaceData[]; + }; + +type TaskActionType = "task_created" | "task_updated" | "task_deleted"; + +// Superset logo for attachment cards +const SUPERSET_ICON_URL = "https://superset.sh/favicon-192.png"; + +/** + * Formats actions into simple text with URLs (for unfurling). + * Used when we have actions - we skip the agent's text and use this instead. + * URLs use web app domain to match unfurl_domains and trigger Slack unfurling. + */ +// Production web app URL for unfurl links (localhost won't unfurl) +const WEB_APP_URL = "https://app.superset.sh"; + +export function formatActionsAsText(actions: AgentAction[]): string { + const lines: string[] = []; + + for (const action of actions) { + if (action.type === "task_created") { + for (const task of action.tasks) { + const url = `${WEB_APP_URL}/task/${task.slug}`; + lines.push(`Created task <${url}|${task.slug}>`); + } + } else if (action.type === "task_updated") { + for (const task of action.tasks) { + const url = `${WEB_APP_URL}/task/${task.slug}`; + lines.push(`Updated task <${url}|${task.slug}>`); + } + } else if (action.type === "task_deleted") { + for (const task of action.tasks) { + lines.push(`Deleted task ${task.slug}`); + } + } else if (action.type === "workspace_created") { + for (const ws of action.workspaces) { + lines.push( + `Created workspace *${ws.name}*${ws.branch ? ` on branch \`${ws.branch}\`` : ""}`, + ); + } + } else if (action.type === "workspace_switched") { + for (const ws of action.workspaces) { + lines.push(`Switched to workspace *${ws.name}*`); + } + } + } + + return lines.join("\n"); +} + +/** + * Creates a rich attachment card for a single task (Linear-style). + * Matches Linear's clean design: icon, title, subtitle, description, status. + */ +function createTaskAttachment( + task: TaskData, + _actionType: TaskActionType, +): MessageAttachment { + const taskUrl = `${env.NEXT_PUBLIC_WEB_URL}/task/${task.slug}`; + + const fields: { title: string; value: string; short: boolean }[] = []; + + // Add status field + if (task.status) { + fields.push({ + title: "Status", + value: task.status, + short: true, + }); + } + + // Add priority field if set and not "none" + if (task.priority && task.priority !== "none") { + fields.push({ + title: "Priority", + value: formatPriority(task.priority), + short: true, + }); + } + + // Build description with task ID subtitle + const subtitle = `Task ${task.slug} in Superset`; + const text = task.description + ? `${subtitle}\n\n${task.description}` + : subtitle; + + return { + color: "#7C3AED", // Superset purple + author_icon: SUPERSET_ICON_URL, + author_name: task.title, + author_link: taskUrl, + text, + fields: fields.length > 0 ? fields : undefined, + ts: String(Math.floor(Date.now() / 1000)), + }; +} + +/** + * Creates a rich attachment card for a workspace (Linear-style). + */ +function createWorkspaceAttachment( + workspace: WorkspaceData, + _actionType: "workspace_created" | "workspace_switched", +): MessageAttachment { + const deepLink = `superset://workspace/${workspace.id}`; + + const fields: { title: string; value: string; short: boolean }[] = []; + + if (workspace.branch) { + fields.push({ + title: "Branch", + value: `\`${workspace.branch}\``, + short: true, + }); + } + + const subtitle = `Workspace in Superset`; + + return { + color: "#7C3AED", // Superset purple + author_icon: SUPERSET_ICON_URL, + author_name: workspace.name, + author_link: deepLink, + text: subtitle, + fields: fields.length > 0 ? fields : undefined, + ts: String(Math.floor(Date.now() / 1000)), + }; +} + +/** + * Creates attachments for an agent action. + */ +function createActionAttachments(action: AgentAction): MessageAttachment[] { + const attachments: MessageAttachment[] = []; + + // Handle task actions + if ( + action.type === "task_created" || + action.type === "task_updated" || + action.type === "task_deleted" + ) { + for (const task of action.tasks) { + attachments.push(createTaskAttachment(task, action.type)); + } + } + + // Handle workspace actions + if ( + action.type === "workspace_created" || + action.type === "workspace_switched" + ) { + for (const workspace of action.workspaces) { + attachments.push(createWorkspaceAttachment(workspace, action.type)); + } + } + + return attachments; +} + +/** + * Creates the full message response for a Slack agent. + * Returns text, blocks for the main message, and attachments for rich cards. + */ +export function createAgentResponse({ + text, + actions, +}: { + text: string; + actions: AgentAction[]; +}): { + text: string; + blocks: KnownBlock[]; + attachments: MessageAttachment[]; +} { + const blocks: KnownBlock[] = []; + const attachments: MessageAttachment[] = []; + + // Add text as a block + if (text) { + blocks.push({ + type: "section", + text: { + type: "mrkdwn", + text, + }, + }); + } + + // Add attachments for each action + for (const action of actions) { + const hasItems = + ("tasks" in action && action.tasks.length > 0) || + ("workspaces" in action && action.workspaces.length > 0); + + if (hasItems) { + attachments.push(...createActionAttachments(action)); + } + } + + return { text, blocks, attachments }; +} + +// Keep the old function for backwards compatibility but mark as deprecated +/** @deprecated Use createAgentResponse instead */ +export function createAgentResponseBlocks({ + text, + actions, +}: { + text: string; + actions: AgentAction[]; +}): KnownBlock[] { + return createAgentResponse({ text, actions }).blocks; +} + +function formatPriority(priority: string): string { + const labels: Record = { + urgent: "🔴 Urgent", + high: "🟠 High", + medium: "🟡 Medium", + low: "🟢 Low", + none: "None", + }; + return labels[priority] ?? priority; +} diff --git a/apps/api/src/lib/slack-agent/slack-client.ts b/apps/api/src/lib/slack-agent/slack-client.ts new file mode 100644 index 00000000000..5d189a2252f --- /dev/null +++ b/apps/api/src/lib/slack-agent/slack-client.ts @@ -0,0 +1,5 @@ +import { WebClient } from "@slack/web-api"; + +export function createSlackClient(token: string): WebClient { + return new WebClient(token); +} diff --git a/apps/api/src/lib/slack-agent/work-objects.ts b/apps/api/src/lib/slack-agent/work-objects.ts new file mode 100644 index 00000000000..71174bb0998 --- /dev/null +++ b/apps/api/src/lib/slack-agent/work-objects.ts @@ -0,0 +1,297 @@ +/** + * Slack Work Objects utilities for tasks. + * + * Work Objects provide rich unfurl cards with flexpane support, + * allowing users to view and interact with Superset data directly in Slack. + * + * @see https://docs.slack.dev/messaging/work-objects/ + */ + +import type { + EntityMetadata, + EntityType, + TaskEntityFields, +} from "@slack/types"; +import type { tasks } from "@superset/db/schema"; + +// Superset branding +const SUPERSET_PRODUCT_NAME = "Superset"; + +// Web app URL (for unfurl matching and navigation) +// TODO: Use env var in production +const WEB_APP_URL = "https://app.superset.sh"; + +// Task with relations from DB query (minimal for unfurl preview) +type TaskWithRelations = typeof tasks.$inferSelect & { + status?: { id: string; name: string } | null; + assignee?: { id: string; name: string | null; email: string } | null; +}; + +// Task with full relations for flexpane +type TaskWithFullRelations = TaskWithRelations & { + creator?: { id: string; name: string | null; email: string } | null; + organization?: { id: string; name: string; slug: string } | null; +}; + +/** + * Creates a Work Object entity for a task. + */ +export function createTaskWorkObject(task: TaskWithRelations): EntityMetadata { + const taskUrl = `${WEB_APP_URL}/task/${task.slug}`; + + const fields: TaskEntityFields = {}; + const displayOrder: string[] = []; + + // Status field (always shown) + fields.status = { + // Padded for spacing in Slack + value: task.status + ? `${task.status.name} ` + : "No status ", + }; + displayOrder.push("status"); + + // Assignee field (conditional) + if (task.assignee) { + fields.assignee = { + type: "slack#/types/user", + user: { + text: task.assignee.name ?? task.assignee.email, + email: task.assignee.email, + }, + }; + displayOrder.push("assignee"); + } + + return { + entity_type: "slack#/entities/task" as EntityType, + url: taskUrl, + app_unfurl_url: taskUrl, + external_ref: { + id: task.id, + type: "task", + }, + entity_payload: { + attributes: { + title: { + text: task.title, + }, + display_id: task.slug, + display_type: "Task", + product_name: SUPERSET_PRODUCT_NAME, + full_size_preview: { + is_supported: false, + }, + metadata_last_modified: Math.floor( + new Date(task.updatedAt).getTime() / 1000, + ), + }, + fields, + display_order: displayOrder, + }, + }; +} + +/** + * Creates a Work Object entity for the flexpane (details view). + * Includes all task fields for the expanded side panel. + */ +export function createTaskFlexpaneObject( + task: TaskWithFullRelations, +): EntityMetadata { + const taskUrl = `${WEB_APP_URL}/task/${task.slug}`; + + const fields: TaskEntityFields = {}; + const displayOrder: string[] = []; + + // Description field + fields.description = { + value: task.description || "No description", + format: "markdown", + }; + displayOrder.push("description"); + + // Status field + fields.status = { + value: task.status?.name ?? "No status", + }; + displayOrder.push("status"); + + // Assignee field + if (task.assignee) { + fields.assignee = { + type: "slack#/types/user", + user: { + text: task.assignee.name ?? task.assignee.email, + email: task.assignee.email, + }, + }; + } else { + fields.assignee = { + type: "string", + value: "_Unassigned_", + format: "markdown", + }; + } + displayOrder.push("assignee"); + + // Priority field + const priorityValue = formatPriorityLabel(task.priority); + fields.priority = + task.priority === "none" + ? { value: "_None_", format: "markdown" } + : { value: priorityValue }; + displayOrder.push("priority"); + + // Custom fields for additional details + const customFields: Array<{ + key: string; + label: string; + type: string; + value?: string | number; + format?: string; + user?: { text: string; email?: string }; + }> = []; + + // Labels + const labels = task.labels as string[] | null; + customFields.push( + labels && labels.length > 0 + ? { + key: "labels", + label: "Labels", + type: "string", + value: labels.join(", "), + } + : { + key: "labels", + label: "Labels", + type: "string", + value: "_None_", + format: "markdown", + }, + ); + + // Organization + customFields.push({ + key: "organization", + label: "Organization", + type: "string", + value: task.organization?.name ?? "—", + }); + + // Created by + if (task.creator) { + customFields.push({ + key: "created_by", + label: "Created by", + type: "slack#/types/user", + user: { + text: task.creator.name ?? task.creator.email, + email: task.creator.email, + }, + }); + } else { + customFields.push({ + key: "created_by", + label: "Created by", + type: "string", + value: "_Unknown_", + format: "markdown", + }); + } + + // Created date + customFields.push({ + key: "created", + label: "Created", + type: "slack#/types/timestamp", + value: Math.floor(new Date(task.createdAt).getTime() / 1000), + }); + + // Updated date + customFields.push({ + key: "updated", + label: "Updated", + type: "slack#/types/timestamp", + value: Math.floor(new Date(task.updatedAt).getTime() / 1000), + }); + + return { + entity_type: "slack#/entities/task" as EntityType, + url: taskUrl, + app_unfurl_url: taskUrl, + external_ref: { + id: task.id, + type: "task", + }, + entity_payload: { + attributes: { + title: { + text: task.title, + }, + display_id: task.slug, + display_type: "Task", + product_name: SUPERSET_PRODUCT_NAME, + full_size_preview: { + is_supported: false, + }, + metadata_last_modified: Math.floor( + new Date(task.updatedAt).getTime() / 1000, + ), + }, + fields, + custom_fields: customFields, + display_order: displayOrder, + actions: { + primary_actions: [ + { + text: "Open in Superset", + action_id: "open_task", + style: "primary", + url: taskUrl, + }, + ], + }, + }, + }; +} + +// Helper functions + +function formatPriorityLabel(priority: string): string { + const labels: Record = { + urgent: "Urgent", + high: "High", + medium: "Medium", + low: "Low", + none: "None", + }; + return labels[priority] ?? priority; +} + +// URL parsing utilities + +/** + * Extract task slug from Superset URL. + * Supports: + * - /api/integrations/slack/task/my-task-slug (legacy API format) + * - /task/my-task-slug (web app format) + */ +export function parseTaskSlugFromUrl(url: string): string | null { + try { + const parsed = new URL(url); + // Try web app format first: /task/{slug} + const webMatch = parsed.pathname.match(/^\/task\/([^/]+)/); + if (webMatch?.[1]) { + return webMatch[1]; + } + // Fall back to legacy API format: /api/integrations/slack/task/{slug} + const apiMatch = parsed.pathname.match( + /^\/api\/integrations\/slack\/task\/([^/]+)/, + ); + return apiMatch?.[1] ?? null; + } catch { + return null; + } +} diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 2536b6d8781..d67f6ea2d0b 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -40,14 +40,37 @@ if (process.defaultApp) { } async function processDeepLink(url: string): Promise { + console.log("[main] Processing deep link:", url); + + // Try auth deep link first (special handling) const authParams = parseAuthDeepLink(url); - if (!authParams) return; + if (authParams) { + const result = await handleAuthCallback(authParams); + if (result.success) { + focusMainWindow(); + } else { + console.error("[main] Auth deep link failed:", result.error); + } + return; + } + + // For all other deep links, extract path and navigate in renderer + // e.g. superset://workspace/123 -> /workspace/123 + try { + const parsed = new URL(url); + const path = parsed.pathname || "/"; - const result = await handleAuthCallback(authParams); - if (result.success) { focusMainWindow(); - } else { - console.error("[main] Auth deep link failed:", result.error); + + // Navigate in renderer via loading the route directly + const windows = BrowserWindow.getAllWindows(); + if (windows.length > 0) { + const mainWindow = windows[0]; + // Send navigation request to renderer + mainWindow.webContents.send("deep-link-navigate", path); + } + } catch (err) { + console.error("[main] Failed to parse deep link:", err); } } diff --git a/apps/desktop/src/renderer/index.tsx b/apps/desktop/src/renderer/index.tsx index d7acfcf71ed..5eaaf0a7fcd 100644 --- a/apps/desktop/src/renderer/index.tsx +++ b/apps/desktop/src/renderer/index.tsx @@ -33,10 +33,18 @@ const unsubscribe = router.subscribe("onResolved", (event) => { }); }); +// Handle deep link navigation from main process +const handleDeepLink = (path: string) => { + console.log("[deep-link] Navigating to:", path); + router.navigate({ to: path }); +}; +window.ipcRenderer.on("deep-link-navigate", handleDeepLink); + // Clean up subscription on HMR if (import.meta.hot) { import.meta.hot.dispose(() => { unsubscribe(); + window.ipcRenderer.off("deep-link-navigate", handleDeepLink); }); } diff --git a/apps/web/src/app/task/[slug]/page.tsx b/apps/web/src/app/task/[slug]/page.tsx new file mode 100644 index 00000000000..96a279c92a6 --- /dev/null +++ b/apps/web/src/app/task/[slug]/page.tsx @@ -0,0 +1,43 @@ +"use client"; + +import Image from "next/image"; +import Link from "next/link"; +import { useParams } from "next/navigation"; +import { useEffect } from "react"; + +/** + * Deep link passthrough page for tasks. + * Attempts to open the Superset desktop app, falls back to dashboard. + */ +export default function TaskDeepLinkPage() { + const params = useParams<{ slug: string }>(); + const slug = params.slug; + const deepLink = `superset://task/${slug}`; + + useEffect(() => { + window.location.href = deepLink; + }, [deepLink]); + + return ( +
+
+ Superset +

+ Redirecting to desktop app... +

+ + Click here if not redirected + +
+
+ ); +} diff --git a/bun.lock b/bun.lock index 91b12187835..63eab479c0c 100644 --- a/bun.lock +++ b/bun.lock @@ -58,6 +58,7 @@ "name": "@superset/api", "version": "0.1.0", "dependencies": { + "@anthropic-ai/sdk": "^0.71.2", "@electric-sql/client": "https://pkg.pr.new/@electric-sql/client@3724", "@linear/sdk": "^68.1.0", "@modelcontextprotocol/sdk": "^1.25.3", @@ -65,6 +66,8 @@ "@octokit/rest": "^22.0.1", "@octokit/webhooks": "^14.2.0", "@sentry/nextjs": "^10.36.0", + "@slack/types": "^2.19.0", + "@slack/web-api": "^7.13.0", "@superset/auth": "workspace:*", "@superset/db": "workspace:*", "@superset/shared": "workspace:*", @@ -1761,6 +1764,12 @@ "@sinonjs/fake-timers": ["@sinonjs/fake-timers@10.3.0", "", { "dependencies": { "@sinonjs/commons": "^3.0.0" } }, "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA=="], + "@slack/logger": ["@slack/logger@4.0.0", "", { "dependencies": { "@types/node": ">=18.0.0" } }, "sha512-Wz7QYfPAlG/DR+DfABddUZeNgoeY7d1J39OCR2jR+v7VBsB8ezulDK5szTnDDPDwLH5IWhLvXIHlCFZV7MSKgA=="], + + "@slack/types": ["@slack/types@2.19.0", "", {}, "sha512-7+QZ38HGcNh/b/7MpvPG6jnw7mliV6UmrquJLqgdxkzJgQEYUcEztvFWRU49z0x4vthF0ixL5lTK601AXrS8IA=="], + + "@slack/web-api": ["@slack/web-api@7.13.0", "", { "dependencies": { "@slack/logger": "^4.0.0", "@slack/types": "^2.18.0", "@types/node": ">=18.0.0", "@types/retry": "0.12.0", "axios": "^1.11.0", "eventemitter3": "^5.0.1", "form-data": "^4.0.4", "is-electron": "2.2.2", "is-stream": "^2", "p-queue": "^6", "p-retry": "^4", "retry": "^0.13.1" } }, "sha512-ERcExbWrnkDN8ovoWWe6Wgt/usanj1dWUd18dJLpctUI4mlPS0nKt81Joh8VI+OPbNnY1lIilVt9gdMBD9U2ig=="], + "@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="], "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], @@ -2151,6 +2160,8 @@ "@types/responselike": ["@types/responselike@1.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw=="], + "@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="], + "@types/semver": ["@types/semver@7.7.1", "", {}, "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA=="], "@types/send": ["@types/send@1.2.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ=="], @@ -2379,6 +2390,8 @@ "ava": ["ava@5.3.1", "", { "dependencies": { "acorn": "^8.8.2", "acorn-walk": "^8.2.0", "ansi-styles": "^6.2.1", "arrgv": "^1.0.2", "arrify": "^3.0.0", "callsites": "^4.0.0", "cbor": "^8.1.0", "chalk": "^5.2.0", "chokidar": "^3.5.3", "chunkd": "^2.0.1", "ci-info": "^3.8.0", "ci-parallel-vars": "^1.0.1", "clean-yaml-object": "^0.1.0", "cli-truncate": "^3.1.0", "code-excerpt": "^4.0.0", "common-path-prefix": "^3.0.0", "concordance": "^5.0.4", "currently-unhandled": "^0.4.1", "debug": "^4.3.4", "emittery": "^1.0.1", "figures": "^5.0.0", "globby": "^13.1.4", "ignore-by-default": "^2.1.0", "indent-string": "^5.0.0", "is-error": "^2.2.2", "is-plain-object": "^5.0.0", "is-promise": "^4.0.0", "matcher": "^5.0.0", "mem": "^9.0.2", "ms": "^2.1.3", "p-event": "^5.0.1", "p-map": "^5.5.0", "picomatch": "^2.3.1", "pkg-conf": "^4.0.0", "plur": "^5.1.0", "pretty-ms": "^8.0.0", "resolve-cwd": "^3.0.0", "stack-utils": "^2.0.6", "strip-ansi": "^7.0.1", "supertap": "^3.0.1", "temp-dir": "^3.0.0", "write-file-atomic": "^5.0.1", "yargs": "^17.7.2" }, "peerDependencies": { "@ava/typescript": "*" }, "optionalPeers": ["@ava/typescript"], "bin": { "ava": "entrypoints/cli.mjs" } }, "sha512-Scv9a4gMOXB6+ni4toLuhAm9KYWEjsgBglJl+kMGI5+IVDt120CCDZyB5HNU9DjmLI2t4I0GbnxGLmmRfGTJGg=="], + "axios": ["axios@1.13.4", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg=="], + "babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.12", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig=="], "babel-jest": ["babel-jest@29.7.0", "", { "dependencies": { "@jest/transform": "^29.7.0", "@types/babel__core": "^7.1.14", "babel-plugin-istanbul": "^6.1.1", "babel-preset-jest": "^29.6.3", "chalk": "^4.0.0", "graceful-fs": "^4.2.9", "slash": "^3.0.0" }, "peerDependencies": { "@babel/core": "^7.8.0" } }, "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg=="], @@ -3363,6 +3376,8 @@ "is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], + "is-electron": ["is-electron@2.2.2", "", {}, "sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg=="], + "is-error": ["is-error@2.2.2", "", {}, "sha512-IOQqts/aHWbiisY5DuPJQ0gcbvaLFCa7fBa9xoLfxBZvQ+ZI/Zh9xoI7Gk+G64N0FdK4AbibytHht2tWgpJWLg=="], "is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="], @@ -3391,7 +3406,7 @@ "is-reference": ["is-reference@1.2.1", "", { "dependencies": { "@types/estree": "*" } }, "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ=="], - "is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="], + "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], "is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], @@ -3929,13 +3944,19 @@ "p-event": ["p-event@5.0.1", "", { "dependencies": { "p-timeout": "^5.0.2" } }, "sha512-dd589iCQ7m1L0bmC5NLlVYfy3TbBEsMUfWx9PyAgPeIcFZ/E2yaTZ4Rz4MiBmmJShviiftHVXOqfnfzJ6kyMrQ=="], + "p-finally": ["p-finally@1.0.0", "", {}, "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow=="], + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], "p-map": ["p-map@5.5.0", "", { "dependencies": { "aggregate-error": "^4.0.0" } }, "sha512-VFqfGDHlx87K66yZrNdI4YGtD70IRyd+zSvgks6mzHPRNkoKy+9EKP4SFC77/vTTQYmRmti7dvqC+m5jBrBAcg=="], - "p-timeout": ["p-timeout@5.1.0", "", {}, "sha512-auFDyzzzGZZZdHz3BtET9VEz0SE/uMEAx7uWfGPucfzEwwe/xH0iVeZibQmANYE/hp9T2+UUZT5m+BKyrDp3Ew=="], + "p-queue": ["p-queue@6.6.2", "", { "dependencies": { "eventemitter3": "^4.0.4", "p-timeout": "^3.2.0" } }, "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ=="], + + "p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="], + + "p-timeout": ["p-timeout@3.2.0", "", { "dependencies": { "p-finally": "^1.0.0" } }, "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg=="], "p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="], @@ -5193,6 +5214,8 @@ "@sentry/webpack-plugin/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], + "@slack/web-api/eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], + "@tailwindcss/node/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], @@ -5349,6 +5372,8 @@ "estree-util-build-jsx/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + "execa/is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="], + "execa/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], "expo/react-refresh": ["react-refresh@0.14.2", "", {}, "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA=="], @@ -5381,6 +5406,8 @@ "fumadocs-ui/lucide-react": ["lucide-react@0.562.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw=="], + "get-stream/is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="], + "glob/minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="], "global-agent/matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="], @@ -5509,6 +5536,8 @@ "ora/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + "p-event/p-timeout": ["p-timeout@5.1.0", "", {}, "sha512-auFDyzzzGZZZdHz3BtET9VEz0SE/uMEAx7uWfGPucfzEwwe/xH0iVeZibQmANYE/hp9T2+UUZT5m+BKyrDp3Ew=="], + "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], diff --git a/packages/db/drizzle/0015_slack_integration.sql b/packages/db/drizzle/0015_slack_integration.sql new file mode 100644 index 00000000000..6c1d713ed80 --- /dev/null +++ b/packages/db/drizzle/0015_slack_integration.sql @@ -0,0 +1 @@ +ALTER TYPE "public"."integration_provider" ADD VALUE 'slack'; \ No newline at end of file diff --git a/packages/db/drizzle/meta/0015_snapshot.json b/packages/db/drizzle/meta/0015_snapshot.json index af5b9e20682..2cfed4b82f4 100644 --- a/packages/db/drizzle/meta/0015_snapshot.json +++ b/packages/db/drizzle/meta/0015_snapshot.json @@ -1,5 +1,5 @@ { - "id": "a0800a2e-f4c6-40f4-8b6e-5f9095173faa", + "id": "0b008ad9-e7a3-4142-afbf-a2746671d214", "prevId": "e2124ad5-d601-4fe4-b6dc-20310002e110", "version": "7", "dialect": "postgresql", @@ -3239,21 +3239,20 @@ }, "compositePrimaryKeys": {}, "uniqueConstraints": { - "tasks_external_unique": { - "name": "tasks_external_unique", + "tasks_slug_unique": { + "name": "tasks_slug_unique", "nullsNotDistinct": false, "columns": [ - "organization_id", - "external_provider", - "external_id" + "slug" ] }, - "tasks_org_slug_unique": { - "name": "tasks_org_slug_unique", + "tasks_external_unique": { + "name": "tasks_external_unique", "nullsNotDistinct": false, "columns": [ "organization_id", - "slug" + "external_provider", + "external_id" ] } }, @@ -3289,7 +3288,8 @@ "schema": "public", "values": [ "linear", - "github" + "github", + "slack" ] }, "public.task_priority": { diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 76a8fa86889..0bc076a166b 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -110,8 +110,8 @@ { "idx": 15, "version": "7", - "when": 1769719998551, - "tag": "0015_scope_tasks_slug_unique_to_org", + "when": 1769559790446, + "tag": "0015_slack_integration", "breakpoints": true } ] diff --git a/packages/db/src/schema/enums.ts b/packages/db/src/schema/enums.ts index d4ca978c868..b25bd5433c7 100644 --- a/packages/db/src/schema/enums.ts +++ b/packages/db/src/schema/enums.ts @@ -23,7 +23,7 @@ export const taskPriorityValues = [ export const taskPriorityEnum = z.enum(taskPriorityValues); export type TaskPriority = z.infer; -export const integrationProviderValues = ["linear", "github"] as const; +export const integrationProviderValues = ["linear", "github", "slack"] as const; export const integrationProviderEnum = z.enum(integrationProviderValues); export type IntegrationProvider = z.infer; diff --git a/packages/db/src/schema/types.ts b/packages/db/src/schema/types.ts index 9b90def9114..271695393c0 100644 --- a/packages/db/src/schema/types.ts +++ b/packages/db/src/schema/types.ts @@ -3,4 +3,10 @@ export type LinearConfig = { newTasksTeamId?: string; }; -export type IntegrationConfig = LinearConfig; +export type SlackConfig = { + provider: "slack"; + botUserId: string; + defaultChannelId?: string; +}; + +export type IntegrationConfig = LinearConfig | SlackConfig; diff --git a/packages/trpc/src/router/integration/integration.ts b/packages/trpc/src/router/integration/integration.ts index ab62013be8a..b07ea3d3496 100644 --- a/packages/trpc/src/router/integration/integration.ts +++ b/packages/trpc/src/router/integration/integration.ts @@ -6,10 +6,12 @@ import { z } from "zod"; import { protectedProcedure } from "../../trpc"; import { githubRouter } from "./github"; import { linearRouter } from "./linear"; +import { slackRouter } from "./slack"; export const integrationRouter = { github: githubRouter, linear: linearRouter, + slack: slackRouter, list: protectedProcedure .input(z.object({ organizationId: z.uuid() })) diff --git a/packages/trpc/src/router/integration/slack/index.ts b/packages/trpc/src/router/integration/slack/index.ts new file mode 100644 index 00000000000..c4ed1e3f7f7 --- /dev/null +++ b/packages/trpc/src/router/integration/slack/index.ts @@ -0,0 +1,6 @@ +export { slackRouter } from "./slack"; +export { + getSlackConnection, + verifyOrgAdmin, + verifyOrgMembership, +} from "./utils"; diff --git a/packages/trpc/src/router/integration/slack/slack.ts b/packages/trpc/src/router/integration/slack/slack.ts new file mode 100644 index 00000000000..bc622adf751 --- /dev/null +++ b/packages/trpc/src/router/integration/slack/slack.ts @@ -0,0 +1,103 @@ +import { db } from "@superset/db/client"; +import { integrationConnections, type SlackConfig } from "@superset/db/schema"; +import type { TRPCRouterRecord } from "@trpc/server"; +import { and, eq } from "drizzle-orm"; +import { z } from "zod"; +import { protectedProcedure } from "../../../trpc"; +import { verifyOrgAdmin, verifyOrgMembership } from "./utils"; + +export const slackRouter = { + getConnection: protectedProcedure + .input(z.object({ organizationId: z.uuid() })) + .query(async ({ ctx, input }) => { + await verifyOrgMembership(ctx.session.user.id, input.organizationId); + + const connection = await db.query.integrationConnections.findFirst({ + where: and( + eq(integrationConnections.organizationId, input.organizationId), + eq(integrationConnections.provider, "slack"), + ), + columns: { + id: true, + externalOrgName: true, + config: true, + createdAt: true, + }, + }); + + if (!connection) return null; + + return { + id: connection.id, + externalOrgName: connection.externalOrgName, + connectedAt: connection.createdAt, + config: connection.config as SlackConfig | null, + }; + }), + + disconnect: protectedProcedure + .input(z.object({ organizationId: z.uuid() })) + .mutation(async ({ ctx, input }) => { + await verifyOrgAdmin(ctx.session.user.id, input.organizationId); + + const result = await db + .delete(integrationConnections) + .where( + and( + eq(integrationConnections.organizationId, input.organizationId), + eq(integrationConnections.provider, "slack"), + ), + ) + .returning({ id: integrationConnections.id }); + + if (result.length === 0) { + return { success: false, error: "No connection found" }; + } + + return { success: true }; + }), + + updateConfig: protectedProcedure + .input( + z.object({ + organizationId: z.uuid(), + defaultChannelId: z.string().optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + await verifyOrgAdmin(ctx.session.user.id, input.organizationId); + + // Get current config to preserve botUserId + const connection = await db.query.integrationConnections.findFirst({ + where: and( + eq(integrationConnections.organizationId, input.organizationId), + eq(integrationConnections.provider, "slack"), + ), + columns: { config: true }, + }); + + if (!connection) { + return { success: false, error: "No connection found" }; + } + + const currentConfig = connection.config as SlackConfig | null; + + const config: SlackConfig = { + provider: "slack", + botUserId: currentConfig?.botUserId ?? "", + defaultChannelId: input.defaultChannelId, + }; + + await db + .update(integrationConnections) + .set({ config }) + .where( + and( + eq(integrationConnections.organizationId, input.organizationId), + eq(integrationConnections.provider, "slack"), + ), + ); + + return { success: true }; + }), +} satisfies TRPCRouterRecord; diff --git a/packages/trpc/src/router/integration/slack/utils.ts b/packages/trpc/src/router/integration/slack/utils.ts new file mode 100644 index 00000000000..e086ea240ee --- /dev/null +++ b/packages/trpc/src/router/integration/slack/utils.ts @@ -0,0 +1,42 @@ +import { db } from "@superset/db/client"; +import { integrationConnections, members } from "@superset/db/schema"; +import { and, eq } from "drizzle-orm"; + +export async function verifyOrgMembership( + userId: string, + organizationId: string, +) { + const membership = await db.query.members.findFirst({ + where: and( + eq(members.organizationId, organizationId), + eq(members.userId, userId), + ), + }); + + if (!membership) { + throw new Error("Not a member of this organization"); + } + + return { membership }; +} + +export async function verifyOrgAdmin(userId: string, organizationId: string) { + const { membership } = await verifyOrgMembership(userId, organizationId); + + if (membership.role !== "admin" && membership.role !== "owner") { + throw new Error("Admin access required"); + } + + return { membership }; +} + +export async function getSlackConnection(organizationId: string) { + const connection = await db.query.integrationConnections.findFirst({ + where: and( + eq(integrationConnections.organizationId, organizationId), + eq(integrationConnections.provider, "slack"), + ), + }); + + return connection ?? null; +} From 963a4a2ca412c0168f68b99126e8ec46c5a67f3d Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Thu, 29 Jan 2026 14:49:51 -0800 Subject: [PATCH 2/8] WIP - untested could be jank --- .mcp.json | 6 +-- apps/api/package.json | 1 + .../src/app/api/agent/[transport]/route.ts | 31 ++----------- apps/api/src/lib/slack-agent/mcp-clients.ts | 31 +++---------- apps/api/src/lib/slack-agent/run-agent.ts | 8 ++-- apps/api/src/lib/slack-agent/slack-blocks.ts | 6 +-- apps/api/src/lib/slack-agent/work-objects.ts | 16 +++---- apps/desktop/src/main/index.ts | 24 +++++----- .../_dashboard/tasks/$taskId/page.tsx | 5 ++- .../src/app/{task => tasks}/[slug]/page.tsx | 2 +- bun.lock | 18 ++++++++ packages/mcp/package.json | 34 ++++++++++++++ .../src/lib/mcp => packages/mcp/src}/auth.ts | 0 packages/mcp/src/in-memory.ts | 44 +++++++++++++++++++ packages/mcp/src/index.ts | 3 ++ packages/mcp/src/server.ts | 11 +++++ .../create-workspace/create-workspace.ts | 0 .../tools/devices/create-workspace/index.ts | 0 .../delete-workspace/delete-workspace.ts | 0 .../tools/devices/delete-workspace/index.ts | 0 .../get-app-context/get-app-context.ts | 0 .../tools/devices/get-app-context/index.ts | 0 .../src}/tools/devices/list-devices/index.ts | 0 .../devices/list-devices/list-devices.ts | 0 .../src}/tools/devices/list-projects/index.ts | 0 .../devices/list-projects/list-projects.ts | 0 .../tools/devices/list-workspaces/index.ts | 0 .../list-workspaces/list-workspaces.ts | 0 .../devices/navigate-to-workspace/index.ts | 0 .../navigate-to-workspace.ts | 0 .../tools/devices/switch-workspace/index.ts | 0 .../switch-workspace/switch-workspace.ts | 0 .../mcp => packages/mcp/src}/tools/index.ts | 0 .../tools/organizations/list-members/index.ts | 0 .../list-members/list-members.ts | 0 .../tools/tasks/create-task/create-task.ts | 0 .../mcp/src}/tools/tasks/create-task/index.ts | 0 .../tools/tasks/delete-task/delete-task.ts | 0 .../mcp/src}/tools/tasks/delete-task/index.ts | 0 .../mcp/src}/tools/tasks/get-task/get-task.ts | 0 .../mcp/src}/tools/tasks/get-task/index.ts | 0 .../tools/tasks/list-task-statuses/index.ts | 0 .../list-task-statuses/list-task-statuses.ts | 0 .../mcp/src}/tools/tasks/list-tasks/index.ts | 0 .../src}/tools/tasks/list-tasks/list-tasks.ts | 0 .../mcp/src}/tools/tasks/update-task/index.ts | 0 .../tools/tasks/update-task/update-task.ts | 0 .../mcp/src}/tools/utils/execute-on-device.ts | 0 .../mcp/src}/tools/utils/index.ts | 0 .../mcp/src}/tools/utils/register-tool.ts | 0 packages/mcp/tsconfig.json | 11 +++++ 51 files changed, 164 insertions(+), 87 deletions(-) rename apps/web/src/app/{task => tasks}/[slug]/page.tsx (95%) create mode 100644 packages/mcp/package.json rename {apps/api/src/lib/mcp => packages/mcp/src}/auth.ts (100%) create mode 100644 packages/mcp/src/in-memory.ts create mode 100644 packages/mcp/src/index.ts create mode 100644 packages/mcp/src/server.ts rename {apps/api/src/lib/mcp => packages/mcp/src}/tools/devices/create-workspace/create-workspace.ts (100%) rename {apps/api/src/lib/mcp => packages/mcp/src}/tools/devices/create-workspace/index.ts (100%) rename {apps/api/src/lib/mcp => packages/mcp/src}/tools/devices/delete-workspace/delete-workspace.ts (100%) rename {apps/api/src/lib/mcp => packages/mcp/src}/tools/devices/delete-workspace/index.ts (100%) rename {apps/api/src/lib/mcp => packages/mcp/src}/tools/devices/get-app-context/get-app-context.ts (100%) rename {apps/api/src/lib/mcp => packages/mcp/src}/tools/devices/get-app-context/index.ts (100%) rename {apps/api/src/lib/mcp => packages/mcp/src}/tools/devices/list-devices/index.ts (100%) rename {apps/api/src/lib/mcp => packages/mcp/src}/tools/devices/list-devices/list-devices.ts (100%) rename {apps/api/src/lib/mcp => packages/mcp/src}/tools/devices/list-projects/index.ts (100%) rename {apps/api/src/lib/mcp => packages/mcp/src}/tools/devices/list-projects/list-projects.ts (100%) rename {apps/api/src/lib/mcp => packages/mcp/src}/tools/devices/list-workspaces/index.ts (100%) rename {apps/api/src/lib/mcp => packages/mcp/src}/tools/devices/list-workspaces/list-workspaces.ts (100%) rename {apps/api/src/lib/mcp => packages/mcp/src}/tools/devices/navigate-to-workspace/index.ts (100%) rename {apps/api/src/lib/mcp => packages/mcp/src}/tools/devices/navigate-to-workspace/navigate-to-workspace.ts (100%) rename {apps/api/src/lib/mcp => packages/mcp/src}/tools/devices/switch-workspace/index.ts (100%) rename {apps/api/src/lib/mcp => packages/mcp/src}/tools/devices/switch-workspace/switch-workspace.ts (100%) rename {apps/api/src/lib/mcp => packages/mcp/src}/tools/index.ts (100%) rename {apps/api/src/lib/mcp => packages/mcp/src}/tools/organizations/list-members/index.ts (100%) rename {apps/api/src/lib/mcp => packages/mcp/src}/tools/organizations/list-members/list-members.ts (100%) rename {apps/api/src/lib/mcp => packages/mcp/src}/tools/tasks/create-task/create-task.ts (100%) rename {apps/api/src/lib/mcp => packages/mcp/src}/tools/tasks/create-task/index.ts (100%) rename {apps/api/src/lib/mcp => packages/mcp/src}/tools/tasks/delete-task/delete-task.ts (100%) rename {apps/api/src/lib/mcp => packages/mcp/src}/tools/tasks/delete-task/index.ts (100%) rename {apps/api/src/lib/mcp => packages/mcp/src}/tools/tasks/get-task/get-task.ts (100%) rename {apps/api/src/lib/mcp => packages/mcp/src}/tools/tasks/get-task/index.ts (100%) rename {apps/api/src/lib/mcp => packages/mcp/src}/tools/tasks/list-task-statuses/index.ts (100%) rename {apps/api/src/lib/mcp => packages/mcp/src}/tools/tasks/list-task-statuses/list-task-statuses.ts (100%) rename {apps/api/src/lib/mcp => packages/mcp/src}/tools/tasks/list-tasks/index.ts (100%) rename {apps/api/src/lib/mcp => packages/mcp/src}/tools/tasks/list-tasks/list-tasks.ts (100%) rename {apps/api/src/lib/mcp => packages/mcp/src}/tools/tasks/update-task/index.ts (100%) rename {apps/api/src/lib/mcp => packages/mcp/src}/tools/tasks/update-task/update-task.ts (100%) rename {apps/api/src/lib/mcp => packages/mcp/src}/tools/utils/execute-on-device.ts (100%) rename {apps/api/src/lib/mcp => packages/mcp/src}/tools/utils/index.ts (100%) rename {apps/api/src/lib/mcp => packages/mcp/src}/tools/utils/register-tool.ts (100%) create mode 100644 packages/mcp/tsconfig.json diff --git a/.mcp.json b/.mcp.json index 03b7976cc3f..ba029a2f9f6 100644 --- a/.mcp.json +++ b/.mcp.json @@ -2,11 +2,11 @@ "mcpServers": { "superset": { "type": "http", - "url": "https://api.superset.sh/api/agent/mcp", + "url": "http://localhost:3001/api/agent/mcp", "oauth": { "clientId": "claude-code", - "authorizationUrl": "https://api.superset.sh/api/auth/mcp/authorize", - "tokenUrl": "https://api.superset.sh/api/auth/mcp/token", + "authorizationUrl": "http://localhost:3001/api/auth/mcp/authorize", + "tokenUrl": "http://localhost:3001/api/auth/mcp/token", "scopes": ["openid", "profile", "email"] } } diff --git a/apps/api/package.json b/apps/api/package.json index c9ce54309f9..9ac03eee334 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -23,6 +23,7 @@ "@slack/web-api": "^7.13.0", "@superset/auth": "workspace:*", "@superset/db": "workspace:*", + "@superset/mcp": "workspace:*", "@superset/shared": "workspace:*", "@superset/trpc": "workspace:*", "@t3-oss/env-nextjs": "^0.13.8", diff --git a/apps/api/src/app/api/agent/[transport]/route.ts b/apps/api/src/app/api/agent/[transport]/route.ts index a07d65462fb..8cec61ec1c7 100644 --- a/apps/api/src/app/api/agent/[transport]/route.ts +++ b/apps/api/src/app/api/agent/[transport]/route.ts @@ -1,34 +1,11 @@ import { auth } from "@superset/auth/server"; +import type { McpContext } from "@superset/mcp/auth"; +import { registerTools } from "@superset/mcp"; import { createMcpHandler, withMcpAuth } from "mcp-handler"; import { env } from "@/env"; -import type { McpContext } from "@/lib/mcp/auth"; -import { registerTools } from "@/lib/mcp/tools"; async function verifyToken(req: Request, bearerToken?: string) { - // 1. Try internal service auth (for Slack agent and other internal services) - const internalOrgId = req.headers.get("X-Internal-Organization-Id"); - const internalUserId = req.headers.get("X-Internal-User-Id"); - if (internalOrgId && internalUserId) { - // Internal requests are trusted when running in the same process - // This is used by the Slack agent to call MCP tools on behalf of users - console.log("[mcp/auth] Internal service auth:", { - organizationId: internalOrgId, - userId: internalUserId, - }); - return { - token: "internal", - clientId: "slack-agent", - scopes: ["mcp:full"], - extra: { - mcpContext: { - userId: internalUserId, - organizationId: internalOrgId, - } satisfies McpContext, - }, - }; - } - - // 2. Try session auth + // 1. Try session auth const session = await auth.api.getSession({ headers: req.headers }); if (session?.session) { const extendedSession = session.session as { @@ -51,7 +28,7 @@ async function verifyToken(req: Request, bearerToken?: string) { }; } - // 3. Try OAuth bearer token + // 2. Try OAuth bearer token if (bearerToken) { const mcpSession = await auth.api.getMcpSession({ headers: req.headers }); if (!mcpSession) return undefined; diff --git a/apps/api/src/lib/slack-agent/mcp-clients.ts b/apps/api/src/lib/slack-agent/mcp-clients.ts index 38ee6ae3153..769d362284f 100644 --- a/apps/api/src/lib/slack-agent/mcp-clients.ts +++ b/apps/api/src/lib/slack-agent/mcp-clients.ts @@ -1,9 +1,7 @@ import type Anthropic from "@anthropic-ai/sdk"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; -import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; - -import { env } from "@/env"; +import { createInMemoryMcpClient } from "@superset/mcp/in-memory"; interface McpTool { name: string; @@ -12,8 +10,8 @@ interface McpTool { } /** - * Creates an MCP client connected to the Superset MCP server via HTTP. - * Uses a service-level authentication bypass for internal agent calls. + * Creates an MCP client connected to the Superset MCP server in-process. + * Uses InMemoryTransport — no HTTP, no forgeable headers. */ export async function createSupersetMcpClient({ organizationId, @@ -21,27 +19,8 @@ export async function createSupersetMcpClient({ }: { organizationId: string; userId: string; -}): Promise { - const transport = new StreamableHTTPClientTransport( - new URL(`${env.NEXT_PUBLIC_API_URL}/api/agent/mcp`), - { - requestInit: { - headers: { - // Pass context as headers for internal service calls - "X-Internal-Organization-Id": organizationId, - "X-Internal-User-Id": userId, - }, - }, - }, - ); - - const client = new Client({ - name: "slack-agent-superset", - version: "1.0.0", - }); - - await client.connect(transport); - return client; +}): Promise<{ client: Client; cleanup: () => Promise }> { + return createInMemoryMcpClient({ organizationId, userId }); } /** diff --git a/apps/api/src/lib/slack-agent/run-agent.ts b/apps/api/src/lib/slack-agent/run-agent.ts index c9f985fd0ee..ebb854a3fb0 100644 --- a/apps/api/src/lib/slack-agent/run-agent.ts +++ b/apps/api/src/lib/slack-agent/run-agent.ts @@ -219,6 +219,7 @@ export async function runSlackAgent( } let supersetMcp: Client | null = null; + let cleanupSuperset: (() => Promise) | null = null; let slackMcp: Client | null = null; try { @@ -244,7 +245,8 @@ export async function runSlackAgent( }), ]); - supersetMcp = supersetMcpResult; + supersetMcp = supersetMcpResult.client; + cleanupSuperset = supersetMcpResult.cleanup; slackMcp = slackMcpResult; if (threadContext) { @@ -401,9 +403,9 @@ Current context: }; } finally { // Cleanup: close MCP clients - if (supersetMcp) { + if (cleanupSuperset) { try { - await supersetMcp.close(); + await cleanupSuperset(); } catch { // Ignore close errors } diff --git a/apps/api/src/lib/slack-agent/slack-blocks.ts b/apps/api/src/lib/slack-agent/slack-blocks.ts index 6854ed33a1b..303a6aa732a 100644 --- a/apps/api/src/lib/slack-agent/slack-blocks.ts +++ b/apps/api/src/lib/slack-agent/slack-blocks.ts @@ -54,12 +54,12 @@ export function formatActionsAsText(actions: AgentAction[]): string { for (const action of actions) { if (action.type === "task_created") { for (const task of action.tasks) { - const url = `${WEB_APP_URL}/task/${task.slug}`; + const url = `${WEB_APP_URL}/tasks/${task.slug}`; lines.push(`Created task <${url}|${task.slug}>`); } } else if (action.type === "task_updated") { for (const task of action.tasks) { - const url = `${WEB_APP_URL}/task/${task.slug}`; + const url = `${WEB_APP_URL}/tasks/${task.slug}`; lines.push(`Updated task <${url}|${task.slug}>`); } } else if (action.type === "task_deleted") { @@ -90,7 +90,7 @@ function createTaskAttachment( task: TaskData, _actionType: TaskActionType, ): MessageAttachment { - const taskUrl = `${env.NEXT_PUBLIC_WEB_URL}/task/${task.slug}`; + const taskUrl = `${env.NEXT_PUBLIC_WEB_URL}/tasks/${task.slug}`; const fields: { title: string; value: string; short: boolean }[] = []; diff --git a/apps/api/src/lib/slack-agent/work-objects.ts b/apps/api/src/lib/slack-agent/work-objects.ts index 71174bb0998..230f4f2fc84 100644 --- a/apps/api/src/lib/slack-agent/work-objects.ts +++ b/apps/api/src/lib/slack-agent/work-objects.ts @@ -37,7 +37,7 @@ type TaskWithFullRelations = TaskWithRelations & { * Creates a Work Object entity for a task. */ export function createTaskWorkObject(task: TaskWithRelations): EntityMetadata { - const taskUrl = `${WEB_APP_URL}/task/${task.slug}`; + const taskUrl = `${WEB_APP_URL}/tasks/${task.slug}`; const fields: TaskEntityFields = {}; const displayOrder: string[] = []; @@ -99,7 +99,7 @@ export function createTaskWorkObject(task: TaskWithRelations): EntityMetadata { export function createTaskFlexpaneObject( task: TaskWithFullRelations, ): EntityMetadata { - const taskUrl = `${WEB_APP_URL}/task/${task.slug}`; + const taskUrl = `${WEB_APP_URL}/tasks/${task.slug}`; const fields: TaskEntityFields = {}; const displayOrder: string[] = []; @@ -275,20 +275,20 @@ function formatPriorityLabel(priority: string): string { /** * Extract task slug from Superset URL. * Supports: - * - /api/integrations/slack/task/my-task-slug (legacy API format) - * - /task/my-task-slug (web app format) + * - /api/integrations/slack/tasks/my-task-slug (legacy API format) + * - /tasks/my-task-slug (web app format) */ export function parseTaskSlugFromUrl(url: string): string | null { try { const parsed = new URL(url); - // Try web app format first: /task/{slug} - const webMatch = parsed.pathname.match(/^\/task\/([^/]+)/); + // Try web app format first: /tasks/{slug} + const webMatch = parsed.pathname.match(/^\/tasks\/([^/]+)/); if (webMatch?.[1]) { return webMatch[1]; } - // Fall back to legacy API format: /api/integrations/slack/task/{slug} + // Fall back to legacy API format: /api/integrations/slack/tasks/{slug} const apiMatch = parsed.pathname.match( - /^\/api\/integrations\/slack\/task\/([^/]+)/, + /^\/api\/integrations\/slack\/tasks\/([^/]+)/, ); return apiMatch?.[1] ?? null; } catch { diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index d67f6ea2d0b..b5c76aa2778 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -55,22 +55,18 @@ async function processDeepLink(url: string): Promise { } // For all other deep links, extract path and navigate in renderer - // e.g. superset://workspace/123 -> /workspace/123 - try { - const parsed = new URL(url); - const path = parsed.pathname || "/"; + // e.g. superset://tasks/my-slug -> /tasks/my-slug + // e.g. superset://settings/integrations -> /settings/integrations + const path = "/" + url.split("://")[1]; - focusMainWindow(); + focusMainWindow(); - // Navigate in renderer via loading the route directly - const windows = BrowserWindow.getAllWindows(); - if (windows.length > 0) { - const mainWindow = windows[0]; - // Send navigation request to renderer - mainWindow.webContents.send("deep-link-navigate", path); - } - } catch (err) { - console.error("[main] Failed to parse deep link:", err); + // Navigate in renderer via loading the route directly + const windows = BrowserWindow.getAllWindows(); + if (windows.length > 0) { + const mainWindow = windows[0]; + // Send navigation request to renderer + mainWindow.webContents.send("deep-link-navigate", path); } } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/page.tsx index a6276ec22b6..97d3be2ec13 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/page.tsx @@ -1,7 +1,7 @@ import { Button } from "@superset/ui/button"; import { ScrollArea } from "@superset/ui/scroll-area"; import { Separator } from "@superset/ui/separator"; -import { eq } from "@tanstack/db"; +import { eq, or } from "@tanstack/db"; import { useLiveQuery } from "@tanstack/react-db"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { useMemo } from "react"; @@ -29,6 +29,7 @@ function TaskDetailPage() { useEscapeToNavigate("/tasks"); + // Support both UUID and slug lookups const { data: taskData } = useLiveQuery( (q) => q @@ -44,7 +45,7 @@ function TaskDetailPage() { status, assignee: assignee ?? null, })) - .where(({ tasks }) => eq(tasks.id, taskId)), + .where(({ tasks }) => or(eq(tasks.id, taskId), eq(tasks.slug, taskId))), [collections, taskId], ); diff --git a/apps/web/src/app/task/[slug]/page.tsx b/apps/web/src/app/tasks/[slug]/page.tsx similarity index 95% rename from apps/web/src/app/task/[slug]/page.tsx rename to apps/web/src/app/tasks/[slug]/page.tsx index 96a279c92a6..0a2a8c9058a 100644 --- a/apps/web/src/app/task/[slug]/page.tsx +++ b/apps/web/src/app/tasks/[slug]/page.tsx @@ -12,7 +12,7 @@ import { useEffect } from "react"; export default function TaskDeepLinkPage() { const params = useParams<{ slug: string }>(); const slug = params.slug; - const deepLink = `superset://task/${slug}`; + const deepLink = `superset://tasks/${slug}`; useEffect(() => { window.location.href = deepLink; diff --git a/bun.lock b/bun.lock index 63eab479c0c..03d8b44a23f 100644 --- a/bun.lock +++ b/bun.lock @@ -70,6 +70,7 @@ "@slack/web-api": "^7.13.0", "@superset/auth": "workspace:*", "@superset/db": "workspace:*", + "@superset/mcp": "workspace:*", "@superset/shared": "workspace:*", "@superset/trpc": "workspace:*", "@t3-oss/env-nextjs": "^0.13.8", @@ -568,6 +569,21 @@ "typescript": "^5.9.3", }, }, + "packages/mcp": { + "name": "@superset/mcp", + "version": "0.1.0", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.25.3", + "@superset/db": "workspace:*", + "drizzle-orm": "0.45.1", + "zod": "^4.3.5", + }, + "devDependencies": { + "@superset/typescript": "workspace:*", + "@types/node": "^24.9.1", + "typescript": "^5.9.3", + }, + }, "packages/scripts": { "name": "@superset/scripts", "version": "0.1.0", @@ -1796,6 +1812,8 @@ "@superset/marketing": ["@superset/marketing@workspace:apps/marketing"], + "@superset/mcp": ["@superset/mcp@workspace:packages/mcp"], + "@superset/mobile": ["@superset/mobile@workspace:apps/mobile"], "@superset/scripts": ["@superset/scripts@workspace:packages/scripts"], diff --git a/packages/mcp/package.json b/packages/mcp/package.json new file mode 100644 index 00000000000..011d340f80a --- /dev/null +++ b/packages/mcp/package.json @@ -0,0 +1,34 @@ +{ + "name": "@superset/mcp", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./auth": { + "types": "./src/auth.ts", + "default": "./src/auth.ts" + }, + "./in-memory": { + "types": "./src/in-memory.ts", + "default": "./src/in-memory.ts" + } + }, + "scripts": { + "typecheck": "tsc --noEmit --emitDeclarationOnly false" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.25.3", + "@superset/db": "workspace:*", + "drizzle-orm": "0.45.1", + "zod": "^4.3.5" + }, + "devDependencies": { + "@superset/typescript": "workspace:*", + "@types/node": "^24.9.1", + "typescript": "^5.9.3" + } +} diff --git a/apps/api/src/lib/mcp/auth.ts b/packages/mcp/src/auth.ts similarity index 100% rename from apps/api/src/lib/mcp/auth.ts rename to packages/mcp/src/auth.ts diff --git a/packages/mcp/src/in-memory.ts b/packages/mcp/src/in-memory.ts new file mode 100644 index 00000000000..8207abaf712 --- /dev/null +++ b/packages/mcp/src/in-memory.ts @@ -0,0 +1,44 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import type { McpContext } from "./auth"; +import { createMcpServer } from "./server"; + +export async function createInMemoryMcpClient({ + organizationId, + userId, +}: { + organizationId: string; + userId: string; +}): Promise<{ client: Client; cleanup: () => Promise }> { + const server = createMcpServer(); + const [serverTransport, clientTransport] = + InMemoryTransport.createLinkedPair(); + + // Inject auth context into every message from client → server + const originalSend = clientTransport.send.bind(clientTransport); + clientTransport.send = (message, options) => + originalSend(message, { + ...options, + authInfo: { + token: "internal", + clientId: "slack-agent", + scopes: ["mcp:full"], + extra: { + mcpContext: { userId, organizationId } satisfies McpContext, + }, + }, + }); + + await server.connect(serverTransport); + + const client = new Client({ name: "superset-internal", version: "1.0.0" }); + await client.connect(clientTransport); + + return { + client, + cleanup: async () => { + await client.close(); + await server.close(); + }, + }; +} diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts new file mode 100644 index 00000000000..d8aca2efbb6 --- /dev/null +++ b/packages/mcp/src/index.ts @@ -0,0 +1,3 @@ +export { registerTools } from "./tools"; +export { createMcpServer } from "./server"; +export type { McpContext } from "./auth"; diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts new file mode 100644 index 00000000000..190dcf8fb96 --- /dev/null +++ b/packages/mcp/src/server.ts @@ -0,0 +1,11 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { registerTools } from "./tools"; + +export function createMcpServer(): McpServer { + const server = new McpServer( + { name: "superset", version: "1.0.0" }, + { capabilities: { tools: {} } }, + ); + registerTools(server); + return server; +} diff --git a/apps/api/src/lib/mcp/tools/devices/create-workspace/create-workspace.ts b/packages/mcp/src/tools/devices/create-workspace/create-workspace.ts similarity index 100% rename from apps/api/src/lib/mcp/tools/devices/create-workspace/create-workspace.ts rename to packages/mcp/src/tools/devices/create-workspace/create-workspace.ts diff --git a/apps/api/src/lib/mcp/tools/devices/create-workspace/index.ts b/packages/mcp/src/tools/devices/create-workspace/index.ts similarity index 100% rename from apps/api/src/lib/mcp/tools/devices/create-workspace/index.ts rename to packages/mcp/src/tools/devices/create-workspace/index.ts diff --git a/apps/api/src/lib/mcp/tools/devices/delete-workspace/delete-workspace.ts b/packages/mcp/src/tools/devices/delete-workspace/delete-workspace.ts similarity index 100% rename from apps/api/src/lib/mcp/tools/devices/delete-workspace/delete-workspace.ts rename to packages/mcp/src/tools/devices/delete-workspace/delete-workspace.ts diff --git a/apps/api/src/lib/mcp/tools/devices/delete-workspace/index.ts b/packages/mcp/src/tools/devices/delete-workspace/index.ts similarity index 100% rename from apps/api/src/lib/mcp/tools/devices/delete-workspace/index.ts rename to packages/mcp/src/tools/devices/delete-workspace/index.ts diff --git a/apps/api/src/lib/mcp/tools/devices/get-app-context/get-app-context.ts b/packages/mcp/src/tools/devices/get-app-context/get-app-context.ts similarity index 100% rename from apps/api/src/lib/mcp/tools/devices/get-app-context/get-app-context.ts rename to packages/mcp/src/tools/devices/get-app-context/get-app-context.ts diff --git a/apps/api/src/lib/mcp/tools/devices/get-app-context/index.ts b/packages/mcp/src/tools/devices/get-app-context/index.ts similarity index 100% rename from apps/api/src/lib/mcp/tools/devices/get-app-context/index.ts rename to packages/mcp/src/tools/devices/get-app-context/index.ts diff --git a/apps/api/src/lib/mcp/tools/devices/list-devices/index.ts b/packages/mcp/src/tools/devices/list-devices/index.ts similarity index 100% rename from apps/api/src/lib/mcp/tools/devices/list-devices/index.ts rename to packages/mcp/src/tools/devices/list-devices/index.ts diff --git a/apps/api/src/lib/mcp/tools/devices/list-devices/list-devices.ts b/packages/mcp/src/tools/devices/list-devices/list-devices.ts similarity index 100% rename from apps/api/src/lib/mcp/tools/devices/list-devices/list-devices.ts rename to packages/mcp/src/tools/devices/list-devices/list-devices.ts diff --git a/apps/api/src/lib/mcp/tools/devices/list-projects/index.ts b/packages/mcp/src/tools/devices/list-projects/index.ts similarity index 100% rename from apps/api/src/lib/mcp/tools/devices/list-projects/index.ts rename to packages/mcp/src/tools/devices/list-projects/index.ts diff --git a/apps/api/src/lib/mcp/tools/devices/list-projects/list-projects.ts b/packages/mcp/src/tools/devices/list-projects/list-projects.ts similarity index 100% rename from apps/api/src/lib/mcp/tools/devices/list-projects/list-projects.ts rename to packages/mcp/src/tools/devices/list-projects/list-projects.ts diff --git a/apps/api/src/lib/mcp/tools/devices/list-workspaces/index.ts b/packages/mcp/src/tools/devices/list-workspaces/index.ts similarity index 100% rename from apps/api/src/lib/mcp/tools/devices/list-workspaces/index.ts rename to packages/mcp/src/tools/devices/list-workspaces/index.ts diff --git a/apps/api/src/lib/mcp/tools/devices/list-workspaces/list-workspaces.ts b/packages/mcp/src/tools/devices/list-workspaces/list-workspaces.ts similarity index 100% rename from apps/api/src/lib/mcp/tools/devices/list-workspaces/list-workspaces.ts rename to packages/mcp/src/tools/devices/list-workspaces/list-workspaces.ts diff --git a/apps/api/src/lib/mcp/tools/devices/navigate-to-workspace/index.ts b/packages/mcp/src/tools/devices/navigate-to-workspace/index.ts similarity index 100% rename from apps/api/src/lib/mcp/tools/devices/navigate-to-workspace/index.ts rename to packages/mcp/src/tools/devices/navigate-to-workspace/index.ts diff --git a/apps/api/src/lib/mcp/tools/devices/navigate-to-workspace/navigate-to-workspace.ts b/packages/mcp/src/tools/devices/navigate-to-workspace/navigate-to-workspace.ts similarity index 100% rename from apps/api/src/lib/mcp/tools/devices/navigate-to-workspace/navigate-to-workspace.ts rename to packages/mcp/src/tools/devices/navigate-to-workspace/navigate-to-workspace.ts diff --git a/apps/api/src/lib/mcp/tools/devices/switch-workspace/index.ts b/packages/mcp/src/tools/devices/switch-workspace/index.ts similarity index 100% rename from apps/api/src/lib/mcp/tools/devices/switch-workspace/index.ts rename to packages/mcp/src/tools/devices/switch-workspace/index.ts diff --git a/apps/api/src/lib/mcp/tools/devices/switch-workspace/switch-workspace.ts b/packages/mcp/src/tools/devices/switch-workspace/switch-workspace.ts similarity index 100% rename from apps/api/src/lib/mcp/tools/devices/switch-workspace/switch-workspace.ts rename to packages/mcp/src/tools/devices/switch-workspace/switch-workspace.ts diff --git a/apps/api/src/lib/mcp/tools/index.ts b/packages/mcp/src/tools/index.ts similarity index 100% rename from apps/api/src/lib/mcp/tools/index.ts rename to packages/mcp/src/tools/index.ts diff --git a/apps/api/src/lib/mcp/tools/organizations/list-members/index.ts b/packages/mcp/src/tools/organizations/list-members/index.ts similarity index 100% rename from apps/api/src/lib/mcp/tools/organizations/list-members/index.ts rename to packages/mcp/src/tools/organizations/list-members/index.ts diff --git a/apps/api/src/lib/mcp/tools/organizations/list-members/list-members.ts b/packages/mcp/src/tools/organizations/list-members/list-members.ts similarity index 100% rename from apps/api/src/lib/mcp/tools/organizations/list-members/list-members.ts rename to packages/mcp/src/tools/organizations/list-members/list-members.ts diff --git a/apps/api/src/lib/mcp/tools/tasks/create-task/create-task.ts b/packages/mcp/src/tools/tasks/create-task/create-task.ts similarity index 100% rename from apps/api/src/lib/mcp/tools/tasks/create-task/create-task.ts rename to packages/mcp/src/tools/tasks/create-task/create-task.ts diff --git a/apps/api/src/lib/mcp/tools/tasks/create-task/index.ts b/packages/mcp/src/tools/tasks/create-task/index.ts similarity index 100% rename from apps/api/src/lib/mcp/tools/tasks/create-task/index.ts rename to packages/mcp/src/tools/tasks/create-task/index.ts diff --git a/apps/api/src/lib/mcp/tools/tasks/delete-task/delete-task.ts b/packages/mcp/src/tools/tasks/delete-task/delete-task.ts similarity index 100% rename from apps/api/src/lib/mcp/tools/tasks/delete-task/delete-task.ts rename to packages/mcp/src/tools/tasks/delete-task/delete-task.ts diff --git a/apps/api/src/lib/mcp/tools/tasks/delete-task/index.ts b/packages/mcp/src/tools/tasks/delete-task/index.ts similarity index 100% rename from apps/api/src/lib/mcp/tools/tasks/delete-task/index.ts rename to packages/mcp/src/tools/tasks/delete-task/index.ts diff --git a/apps/api/src/lib/mcp/tools/tasks/get-task/get-task.ts b/packages/mcp/src/tools/tasks/get-task/get-task.ts similarity index 100% rename from apps/api/src/lib/mcp/tools/tasks/get-task/get-task.ts rename to packages/mcp/src/tools/tasks/get-task/get-task.ts diff --git a/apps/api/src/lib/mcp/tools/tasks/get-task/index.ts b/packages/mcp/src/tools/tasks/get-task/index.ts similarity index 100% rename from apps/api/src/lib/mcp/tools/tasks/get-task/index.ts rename to packages/mcp/src/tools/tasks/get-task/index.ts diff --git a/apps/api/src/lib/mcp/tools/tasks/list-task-statuses/index.ts b/packages/mcp/src/tools/tasks/list-task-statuses/index.ts similarity index 100% rename from apps/api/src/lib/mcp/tools/tasks/list-task-statuses/index.ts rename to packages/mcp/src/tools/tasks/list-task-statuses/index.ts diff --git a/apps/api/src/lib/mcp/tools/tasks/list-task-statuses/list-task-statuses.ts b/packages/mcp/src/tools/tasks/list-task-statuses/list-task-statuses.ts similarity index 100% rename from apps/api/src/lib/mcp/tools/tasks/list-task-statuses/list-task-statuses.ts rename to packages/mcp/src/tools/tasks/list-task-statuses/list-task-statuses.ts diff --git a/apps/api/src/lib/mcp/tools/tasks/list-tasks/index.ts b/packages/mcp/src/tools/tasks/list-tasks/index.ts similarity index 100% rename from apps/api/src/lib/mcp/tools/tasks/list-tasks/index.ts rename to packages/mcp/src/tools/tasks/list-tasks/index.ts diff --git a/apps/api/src/lib/mcp/tools/tasks/list-tasks/list-tasks.ts b/packages/mcp/src/tools/tasks/list-tasks/list-tasks.ts similarity index 100% rename from apps/api/src/lib/mcp/tools/tasks/list-tasks/list-tasks.ts rename to packages/mcp/src/tools/tasks/list-tasks/list-tasks.ts diff --git a/apps/api/src/lib/mcp/tools/tasks/update-task/index.ts b/packages/mcp/src/tools/tasks/update-task/index.ts similarity index 100% rename from apps/api/src/lib/mcp/tools/tasks/update-task/index.ts rename to packages/mcp/src/tools/tasks/update-task/index.ts diff --git a/apps/api/src/lib/mcp/tools/tasks/update-task/update-task.ts b/packages/mcp/src/tools/tasks/update-task/update-task.ts similarity index 100% rename from apps/api/src/lib/mcp/tools/tasks/update-task/update-task.ts rename to packages/mcp/src/tools/tasks/update-task/update-task.ts diff --git a/apps/api/src/lib/mcp/tools/utils/execute-on-device.ts b/packages/mcp/src/tools/utils/execute-on-device.ts similarity index 100% rename from apps/api/src/lib/mcp/tools/utils/execute-on-device.ts rename to packages/mcp/src/tools/utils/execute-on-device.ts diff --git a/apps/api/src/lib/mcp/tools/utils/index.ts b/packages/mcp/src/tools/utils/index.ts similarity index 100% rename from apps/api/src/lib/mcp/tools/utils/index.ts rename to packages/mcp/src/tools/utils/index.ts diff --git a/apps/api/src/lib/mcp/tools/utils/register-tool.ts b/packages/mcp/src/tools/utils/register-tool.ts similarity index 100% rename from apps/api/src/lib/mcp/tools/utils/register-tool.ts rename to packages/mcp/src/tools/utils/register-tool.ts diff --git a/packages/mcp/tsconfig.json b/packages/mcp/tsconfig.json new file mode 100644 index 00000000000..525620cf0a6 --- /dev/null +++ b/packages/mcp/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@superset/typescript/internal-package.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"], + "exclude": ["node_modules"] +} From 9d4be797d78ad2d4b38ddf86b064c4e13e027705 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Thu, 29 Jan 2026 15:15:06 -0800 Subject: [PATCH 3/8] WIP - untested could be jank --- .../IntegrationsSettings.tsx | 25 +- .../utils/settings-search/settings-search.ts | 19 + .../src/app/(dashboard)/integrations/page.tsx | 10 +- .../ConnectionControls/ConnectionControls.tsx | 84 + .../components/ConnectionControls/index.ts | 1 + .../components/ErrorHandler/ErrorHandler.tsx | 28 + .../slack/components/ErrorHandler/index.ts | 1 + .../(dashboard)/integrations/slack/page.tsx | 94 + .../db/drizzle/0016_slack_integration.sql | 1 + packages/db/drizzle/meta/0015_snapshot.json | 20 +- packages/db/drizzle/meta/0016_snapshot.json | 3335 +++++++++++++++++ packages/db/drizzle/meta/_journal.json | 11 +- 12 files changed, 3615 insertions(+), 14 deletions(-) create mode 100644 apps/web/src/app/(dashboard)/integrations/slack/components/ConnectionControls/ConnectionControls.tsx create mode 100644 apps/web/src/app/(dashboard)/integrations/slack/components/ConnectionControls/index.ts create mode 100644 apps/web/src/app/(dashboard)/integrations/slack/components/ErrorHandler/ErrorHandler.tsx create mode 100644 apps/web/src/app/(dashboard)/integrations/slack/components/ErrorHandler/index.ts create mode 100644 apps/web/src/app/(dashboard)/integrations/slack/page.tsx create mode 100644 packages/db/drizzle/0016_slack_integration.sql create mode 100644 packages/db/drizzle/meta/0016_snapshot.json diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/integrations/components/IntegrationsSettings/IntegrationsSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/integrations/components/IntegrationsSettings/IntegrationsSettings.tsx index 57cbbe32899..558e6b206d8 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/integrations/components/IntegrationsSettings/IntegrationsSettings.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/integrations/components/IntegrationsSettings/IntegrationsSettings.tsx @@ -11,7 +11,7 @@ import { Skeleton } from "@superset/ui/skeleton"; import { useLiveQuery } from "@tanstack/react-db"; import { useFeatureFlagEnabled } from "posthog-js/react"; import { useCallback, useEffect, useState } from "react"; -import { FaGithub } from "react-icons/fa"; +import { FaGithub, FaSlack } from "react-icons/fa"; import { HiCheckCircle, HiOutlineArrowTopRightOnSquare } from "react-icons/hi2"; import { SiLinear } from "react-icons/si"; import { @@ -97,11 +97,18 @@ export function IntegrationsSettings({ }, [fetchGithubInstallation]); const linearConnection = integrations?.find((i) => i.provider === "linear"); + const slackConnection = integrations?.find((i) => i.provider === "slack"); const isLinearConnected = !!linearConnection; + const isSlackConnected = !!slackConnection; const isGithubConnected = !!githubInstallation && !githubInstallation.suspended; const isLoading = isLoadingIntegrations || isLoadingGithub; + const showSlack = isItemVisible( + SETTING_ITEM_ID.INTEGRATIONS_SLACK, + visibleItems, + ); + const handleOpenWeb = (path: string) => { window.open(`${env.NEXT_PUBLIC_WEB_URL}${path}`, "_blank"); }; @@ -163,6 +170,22 @@ export function IntegrationsSettings({ } /> )} + + {showSlack && ( + } + isConnected={isSlackConnected} + connectedOrgName={slackConnection?.externalOrgName} + isLoading={isLoading} + onManage={() => + gateFeature(GATED_FEATURES.INTEGRATIONS, () => + handleOpenWeb("/integrations/slack"), + ) + } + /> + )}

diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts index b09d5a905c0..568eeaa9f60 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts @@ -32,6 +32,7 @@ export const SETTING_ITEM_ID = { INTEGRATIONS_LINEAR: "integrations-linear", INTEGRATIONS_GITHUB: "integrations-github", + INTEGRATIONS_SLACK: "integrations-slack", BILLING_OVERVIEW: "billing-overview", BILLING_PLANS: "billing-plans", @@ -473,6 +474,24 @@ export const SETTINGS_ITEMS: SettingsItem[] = [ "git", ], }, + { + id: SETTING_ITEM_ID.INTEGRATIONS_SLACK, + section: "integrations", + title: "Slack", + description: "Manage tasks from Slack conversations", + keywords: [ + "integrations", + "slack", + "messages", + "conversations", + "tasks", + "chat", + "sync", + "connect", + "connected", + "communication", + ], + }, { id: SETTING_ITEM_ID.BILLING_OVERVIEW, section: "billing", diff --git a/apps/web/src/app/(dashboard)/integrations/page.tsx b/apps/web/src/app/(dashboard)/integrations/page.tsx index a920b03691d..c4971b34c1b 100644 --- a/apps/web/src/app/(dashboard)/integrations/page.tsx +++ b/apps/web/src/app/(dashboard)/integrations/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { FaGithub } from "react-icons/fa"; +import { FaGithub, FaSlack } from "react-icons/fa"; import { SiLinear } from "react-icons/si"; import { IntegrationCard, @@ -24,6 +24,14 @@ const integrations: IntegrationCardProps[] = [ accentColor: "#238636", icon: , }, + { + id: "slack", + name: "Slack", + description: "Connect Slack to manage tasks from conversations.", + category: "Communication", + accentColor: "#4A154B", + icon: , + }, ]; export default function IntegrationsPage() { diff --git a/apps/web/src/app/(dashboard)/integrations/slack/components/ConnectionControls/ConnectionControls.tsx b/apps/web/src/app/(dashboard)/integrations/slack/components/ConnectionControls/ConnectionControls.tsx new file mode 100644 index 00000000000..5544538dd7d --- /dev/null +++ b/apps/web/src/app/(dashboard)/integrations/slack/components/ConnectionControls/ConnectionControls.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@superset/ui/alert-dialog"; +import { Button } from "@superset/ui/button"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Unplug } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { env } from "@/env"; +import { useTRPC } from "@/trpc/react"; + +interface ConnectionControlsProps { + organizationId: string; + isConnected: boolean; +} + +export function ConnectionControls({ + organizationId, + isConnected, +}: ConnectionControlsProps) { + const trpc = useTRPC(); + const router = useRouter(); + const queryClient = useQueryClient(); + + const disconnectMutation = useMutation( + trpc.integration.slack.disconnect.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.integration.slack.getConnection.queryKey({ + organizationId, + }), + }); + router.refresh(); + }, + }), + ); + + const handleConnect = () => { + window.location.href = `${env.NEXT_PUBLIC_API_URL}/api/integrations/slack/connect?organizationId=${organizationId}`; + }; + + const handleDisconnect = () => { + disconnectMutation.mutate({ organizationId }); + }; + + if (isConnected) { + return ( + + + + + + + Disconnect Slack? + + This will remove the connection between your organization and + Slack. You can reconnect at any time. + + + + Cancel + + Disconnect + + + + + ); + } + + return ; +} diff --git a/apps/web/src/app/(dashboard)/integrations/slack/components/ConnectionControls/index.ts b/apps/web/src/app/(dashboard)/integrations/slack/components/ConnectionControls/index.ts new file mode 100644 index 00000000000..ca060e28397 --- /dev/null +++ b/apps/web/src/app/(dashboard)/integrations/slack/components/ConnectionControls/index.ts @@ -0,0 +1 @@ +export { ConnectionControls } from "./ConnectionControls"; diff --git a/apps/web/src/app/(dashboard)/integrations/slack/components/ErrorHandler/ErrorHandler.tsx b/apps/web/src/app/(dashboard)/integrations/slack/components/ErrorHandler/ErrorHandler.tsx new file mode 100644 index 00000000000..2933d235491 --- /dev/null +++ b/apps/web/src/app/(dashboard)/integrations/slack/components/ErrorHandler/ErrorHandler.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { toast } from "@superset/ui/sonner"; +import { useSearchParams } from "next/navigation"; +import { useEffect } from "react"; + +const ERROR_MESSAGES: Record = { + oauth_denied: "Authorization was denied. Please try again.", + missing_params: "Invalid OAuth response. Please try again.", + invalid_state: "Invalid state parameter. Please try again.", + token_exchange_failed: "Failed to connect to Slack. Please try again.", + slack_api_error: "Slack API error occurred. Please try again.", + unauthorized: "You are not authorized to perform this action.", +}; + +export function ErrorHandler() { + const searchParams = useSearchParams(); + + useEffect(() => { + const error = searchParams.get("error"); + if (error) { + toast.error(ERROR_MESSAGES[error] ?? "Something went wrong."); + window.history.replaceState({}, "", "/integrations/slack"); + } + }, [searchParams]); + + return null; +} diff --git a/apps/web/src/app/(dashboard)/integrations/slack/components/ErrorHandler/index.ts b/apps/web/src/app/(dashboard)/integrations/slack/components/ErrorHandler/index.ts new file mode 100644 index 00000000000..c5e593d602a --- /dev/null +++ b/apps/web/src/app/(dashboard)/integrations/slack/components/ErrorHandler/index.ts @@ -0,0 +1 @@ +export { ErrorHandler } from "./ErrorHandler"; diff --git a/apps/web/src/app/(dashboard)/integrations/slack/page.tsx b/apps/web/src/app/(dashboard)/integrations/slack/page.tsx new file mode 100644 index 00000000000..27ce281b158 --- /dev/null +++ b/apps/web/src/app/(dashboard)/integrations/slack/page.tsx @@ -0,0 +1,94 @@ +import { Badge } from "@superset/ui/badge"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@superset/ui/card"; +import { ArrowLeft, CheckCircle2 } from "lucide-react"; +import Link from "next/link"; +import { FaSlack } from "react-icons/fa"; +import { api } from "@/trpc/server"; +import { ConnectionControls } from "./components/ConnectionControls"; +import { ErrorHandler } from "./components/ErrorHandler"; + +export default async function SlackIntegrationPage() { + const trpc = await api(); + const organization = await trpc.user.myOrganization.query(); + + if (!organization) { + return ( +

+

+ You need to be part of an organization to use integrations. +

+
+ ); + } + + const connection = await trpc.integration.slack.getConnection.query({ + organizationId: organization.id, + }); + const isConnected = !!connection; + + return ( +
+ + + + + Back to Integrations + + +
+
+ +
+
+
+

Slack

+ {isConnected ? ( + + + Connected + + ) : ( + Not Connected + )} +
+

+ Connect Slack to manage tasks from conversations. Mention the bot in + any channel or send it a direct message to create and update tasks. +

+
+
+ + + + Connection + + Connect your Slack workspace to manage tasks from conversations. + + + + + {connection && ( +
+ Connected to{" "} + + {connection.externalOrgName} + +
+ )} +
+
+
+ ); +} diff --git a/packages/db/drizzle/0016_slack_integration.sql b/packages/db/drizzle/0016_slack_integration.sql new file mode 100644 index 00000000000..6c1d713ed80 --- /dev/null +++ b/packages/db/drizzle/0016_slack_integration.sql @@ -0,0 +1 @@ +ALTER TYPE "public"."integration_provider" ADD VALUE 'slack'; \ No newline at end of file diff --git a/packages/db/drizzle/meta/0015_snapshot.json b/packages/db/drizzle/meta/0015_snapshot.json index 2cfed4b82f4..af5b9e20682 100644 --- a/packages/db/drizzle/meta/0015_snapshot.json +++ b/packages/db/drizzle/meta/0015_snapshot.json @@ -1,5 +1,5 @@ { - "id": "0b008ad9-e7a3-4142-afbf-a2746671d214", + "id": "a0800a2e-f4c6-40f4-8b6e-5f9095173faa", "prevId": "e2124ad5-d601-4fe4-b6dc-20310002e110", "version": "7", "dialect": "postgresql", @@ -3239,13 +3239,6 @@ }, "compositePrimaryKeys": {}, "uniqueConstraints": { - "tasks_slug_unique": { - "name": "tasks_slug_unique", - "nullsNotDistinct": false, - "columns": [ - "slug" - ] - }, "tasks_external_unique": { "name": "tasks_external_unique", "nullsNotDistinct": false, @@ -3254,6 +3247,14 @@ "external_provider", "external_id" ] + }, + "tasks_org_slug_unique": { + "name": "tasks_org_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "slug" + ] } }, "policies": {}, @@ -3288,8 +3289,7 @@ "schema": "public", "values": [ "linear", - "github", - "slack" + "github" ] }, "public.task_priority": { diff --git a/packages/db/drizzle/meta/0016_snapshot.json b/packages/db/drizzle/meta/0016_snapshot.json new file mode 100644 index 00000000000..206cd75ff77 --- /dev/null +++ b/packages/db/drizzle/meta/0016_snapshot.json @@ -0,0 +1,3335 @@ +{ + "id": "42baff60-2e3b-4f60-a33f-ad939180a08a", + "prevId": "a0800a2e-f4c6-40f4-8b6e-5f9095173faa", + "version": "7", + "dialect": "postgresql", + "tables": { + "auth.accounts": { + "name": "accounts", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "accounts_user_id_idx": { + "name": "accounts_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.apikeys": { + "name": "apikeys", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 86400000 + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_request": { + "name": "last_request", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "apikeys_key_idx": { + "name": "apikeys_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "apikeys_user_id_idx": { + "name": "apikeys_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "apikeys_user_id_users_id_fk": { + "name": "apikeys_user_id_users_id_fk", + "tableFrom": "apikeys", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.invitations": { + "name": "invitations", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "inviter_id": { + "name": "inviter_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "invitations_organization_id_idx": { + "name": "invitations_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitations_email_idx": { + "name": "invitations_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitations_organization_id_organizations_id_fk": { + "name": "invitations_organization_id_organizations_id_fk", + "tableFrom": "invitations", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitations_inviter_id_users_id_fk": { + "name": "invitations_inviter_id_users_id_fk", + "tableFrom": "invitations", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.members": { + "name": "members", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "members_organization_id_idx": { + "name": "members_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "members_user_id_idx": { + "name": "members_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "members_organization_id_organizations_id_fk": { + "name": "members_organization_id_organizations_id_fk", + "tableFrom": "members", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "members_user_id_users_id_fk": { + "name": "members_user_id_users_id_fk", + "tableFrom": "members", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.oauth_access_tokens": { + "name": "oauth_access_tokens", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "oauth_access_tokens_client_id_idx": { + "name": "oauth_access_tokens_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_access_tokens_user_id_idx": { + "name": "oauth_access_tokens_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_access_tokens_client_id_oauth_applications_client_id_fk": { + "name": "oauth_access_tokens_client_id_oauth_applications_client_id_fk", + "tableFrom": "oauth_access_tokens", + "tableTo": "oauth_applications", + "schemaTo": "auth", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_tokens_user_id_users_id_fk": { + "name": "oauth_access_tokens_user_id_users_id_fk", + "tableFrom": "oauth_access_tokens", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_tokens_access_token_unique": { + "name": "oauth_access_tokens_access_token_unique", + "nullsNotDistinct": false, + "columns": [ + "access_token" + ] + }, + "oauth_access_tokens_refresh_token_unique": { + "name": "oauth_access_tokens_refresh_token_unique", + "nullsNotDistinct": false, + "columns": [ + "refresh_token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.oauth_applications": { + "name": "oauth_applications", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_urls": { + "name": "redirect_urls", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "oauth_applications_user_id_idx": { + "name": "oauth_applications_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_applications_user_id_users_id_fk": { + "name": "oauth_applications_user_id_users_id_fk", + "tableFrom": "oauth_applications", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_applications_client_id_unique": { + "name": "oauth_applications_client_id_unique", + "nullsNotDistinct": false, + "columns": [ + "client_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.oauth_consents": { + "name": "oauth_consents", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "consent_given": { + "name": "consent_given", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "oauth_consents_client_id_idx": { + "name": "oauth_consents_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_consents_user_id_idx": { + "name": "oauth_consents_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_consents_client_id_oauth_applications_client_id_fk": { + "name": "oauth_consents_client_id_oauth_applications_client_id_fk", + "tableFrom": "oauth_consents", + "tableTo": "oauth_applications", + "schemaTo": "auth", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consents_user_id_users_id_fk": { + "name": "oauth_consents_user_id_users_id_fk", + "tableFrom": "oauth_consents", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.organizations": { + "name": "organizations", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "organizations_slug_idx": { + "name": "organizations_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.sessions": { + "name": "sessions", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.users": { + "name": "users", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_ids": { + "name": "organization_ids", + "type": "uuid[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.verifications": { + "name": "verifications", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verifications_identifier_idx": { + "name": "verifications_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_installations": { + "name": "github_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "connected_by_user_id": { + "name": "connected_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_login": { + "name": "account_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_type": { + "name": "account_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "suspended": { + "name": "suspended", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_installations_installation_id_idx": { + "name": "github_installations_installation_id_idx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_installations_organization_id_organizations_id_fk": { + "name": "github_installations_organization_id_organizations_id_fk", + "tableFrom": "github_installations", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_installations_connected_by_user_id_users_id_fk": { + "name": "github_installations_connected_by_user_id_users_id_fk", + "tableFrom": "github_installations", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "connected_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_installations_installation_id_unique": { + "name": "github_installations_installation_id_unique", + "nullsNotDistinct": false, + "columns": [ + "installation_id" + ] + }, + "github_installations_org_unique": { + "name": "github_installations_org_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_pull_requests": { + "name": "github_pull_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "node_id": { + "name": "node_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "head_branch": { + "name": "head_branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "head_sha": { + "name": "head_sha", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "base_branch": { + "name": "base_branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_login": { + "name": "author_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_avatar_url": { + "name": "author_avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_draft": { + "name": "is_draft", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "additions": { + "name": "additions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "deletions": { + "name": "deletions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "changed_files": { + "name": "changed_files", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "review_decision": { + "name": "review_decision", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checks_status": { + "name": "checks_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "checks": { + "name": "checks", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "merged_at": { + "name": "merged_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_pull_requests_repository_id_idx": { + "name": "github_pull_requests_repository_id_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_pull_requests_state_idx": { + "name": "github_pull_requests_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_pull_requests_head_branch_idx": { + "name": "github_pull_requests_head_branch_idx", + "columns": [ + { + "expression": "head_branch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_pull_requests_repository_id_github_repositories_id_fk": { + "name": "github_pull_requests_repository_id_github_repositories_id_fk", + "tableFrom": "github_pull_requests", + "tableTo": "github_repositories", + "columnsFrom": [ + "repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_pull_requests_repo_pr_unique": { + "name": "github_pull_requests_repo_pr_unique", + "nullsNotDistinct": false, + "columns": [ + "repository_id", + "pr_number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_repositories": { + "name": "github_repositories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "installation_id": { + "name": "installation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "repo_id": { + "name": "repo_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "full_name": { + "name": "full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'main'" + }, + "is_private": { + "name": "is_private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_repositories_installation_id_idx": { + "name": "github_repositories_installation_id_idx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_repositories_full_name_idx": { + "name": "github_repositories_full_name_idx", + "columns": [ + { + "expression": "full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_repositories_installation_id_github_installations_id_fk": { + "name": "github_repositories_installation_id_github_installations_id_fk", + "tableFrom": "github_repositories", + "tableTo": "github_installations", + "columnsFrom": [ + "installation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_repositories_repo_id_unique": { + "name": "github_repositories_repo_id_unique", + "nullsNotDistinct": false, + "columns": [ + "repo_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "ingest.webhook_events": { + "name": "webhook_events", + "schema": "ingest", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "integration_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "retry_count": { + "name": "retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "received_at": { + "name": "received_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhook_events_provider_status_idx": { + "name": "webhook_events_provider_status_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_events_provider_event_id_idx": { + "name": "webhook_events_provider_event_id_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_events_received_at_idx": { + "name": "webhook_events_received_at_idx", + "columns": [ + { + "expression": "received_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_commands": { + "name": "agent_commands", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_device_id": { + "name": "target_device_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_device_type": { + "name": "target_device_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool": { + "name": "tool", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "parent_command_id": { + "name": "parent_command_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "command_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "executed_at": { + "name": "executed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "timeout_at": { + "name": "timeout_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "agent_commands_user_status_idx": { + "name": "agent_commands_user_status_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_commands_target_device_status_idx": { + "name": "agent_commands_target_device_status_idx", + "columns": [ + { + "expression": "target_device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_commands_org_created_idx": { + "name": "agent_commands_org_created_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_commands_user_id_users_id_fk": { + "name": "agent_commands_user_id_users_id_fk", + "tableFrom": "agent_commands", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_commands_organization_id_organizations_id_fk": { + "name": "agent_commands_organization_id_organizations_id_fk", + "tableFrom": "agent_commands", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_presence": { + "name": "device_presence", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_name": { + "name": "device_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "device_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "device_presence_user_org_idx": { + "name": "device_presence_user_org_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_presence_user_device_idx": { + "name": "device_presence_user_device_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_presence_last_seen_idx": { + "name": "device_presence_last_seen_idx", + "columns": [ + { + "expression": "last_seen_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "device_presence_user_id_users_id_fk": { + "name": "device_presence_user_id_users_id_fk", + "tableFrom": "device_presence", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "device_presence_organization_id_organizations_id_fk": { + "name": "device_presence_organization_id_organizations_id_fk", + "tableFrom": "device_presence", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration_connections": { + "name": "integration_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "connected_by_user_id": { + "name": "connected_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "integration_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_expires_at": { + "name": "token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "external_org_id": { + "name": "external_org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_org_name": { + "name": "external_org_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "integration_connections_org_idx": { + "name": "integration_connections_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "integration_connections_organization_id_organizations_id_fk": { + "name": "integration_connections_organization_id_organizations_id_fk", + "tableFrom": "integration_connections", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integration_connections_connected_by_user_id_users_id_fk": { + "name": "integration_connections_connected_by_user_id_users_id_fk", + "tableFrom": "integration_connections", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "connected_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "integration_connections_unique": { + "name": "integration_connections_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "provider" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.repositories": { + "name": "repositories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_owner": { + "name": "repo_owner", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_name": { + "name": "repo_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'main'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "repositories_organization_id_idx": { + "name": "repositories_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "repositories_slug_idx": { + "name": "repositories_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "repositories_organization_id_organizations_id_fk": { + "name": "repositories_organization_id_organizations_id_fk", + "tableFrom": "repositories", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "repositories_org_slug_unique": { + "name": "repositories_org_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscriptions": { + "name": "subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'incomplete'" + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "subscriptions_reference_id_idx": { + "name": "subscriptions_reference_id_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "subscriptions_stripe_customer_id_idx": { + "name": "subscriptions_stripe_customer_id_idx", + "columns": [ + { + "expression": "stripe_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "subscriptions_status_idx": { + "name": "subscriptions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "subscriptions_reference_id_organizations_id_fk": { + "name": "subscriptions_reference_id_organizations_id_fk", + "tableFrom": "subscriptions", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "reference_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_statuses": { + "name": "task_statuses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "progress_percent": { + "name": "progress_percent", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "external_provider": { + "name": "external_provider", + "type": "integration_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_statuses_organization_id_idx": { + "name": "task_statuses_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_statuses_type_idx": { + "name": "task_statuses_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_statuses_organization_id_organizations_id_fk": { + "name": "task_statuses_organization_id_organizations_id_fk", + "tableFrom": "task_statuses", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "task_statuses_org_external_unique": { + "name": "task_statuses_org_external_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "external_provider", + "external_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tasks": { + "name": "tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_id": { + "name": "status_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "task_priority", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "creator_id": { + "name": "creator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "estimate": { + "name": "estimate", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "due_date": { + "name": "due_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "labels": { + "name": "labels", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_provider": { + "name": "external_provider", + "type": "integration_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_key": { + "name": "external_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_url": { + "name": "external_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "sync_error": { + "name": "sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tasks_slug_idx": { + "name": "tasks_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_organization_id_idx": { + "name": "tasks_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_repository_id_idx": { + "name": "tasks_repository_id_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_assignee_id_idx": { + "name": "tasks_assignee_id_idx", + "columns": [ + { + "expression": "assignee_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_creator_id_idx": { + "name": "tasks_creator_id_idx", + "columns": [ + { + "expression": "creator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_status_id_idx": { + "name": "tasks_status_id_idx", + "columns": [ + { + "expression": "status_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_created_at_idx": { + "name": "tasks_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_external_provider_idx": { + "name": "tasks_external_provider_idx", + "columns": [ + { + "expression": "external_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tasks_status_id_task_statuses_id_fk": { + "name": "tasks_status_id_task_statuses_id_fk", + "tableFrom": "tasks", + "tableTo": "task_statuses", + "columnsFrom": [ + "status_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "tasks_organization_id_organizations_id_fk": { + "name": "tasks_organization_id_organizations_id_fk", + "tableFrom": "tasks", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_repository_id_repositories_id_fk": { + "name": "tasks_repository_id_repositories_id_fk", + "tableFrom": "tasks", + "tableTo": "repositories", + "columnsFrom": [ + "repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_assignee_id_users_id_fk": { + "name": "tasks_assignee_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tasks_creator_id_users_id_fk": { + "name": "tasks_creator_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tasks_external_unique": { + "name": "tasks_external_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "external_provider", + "external_id" + ] + }, + "tasks_org_slug_unique": { + "name": "tasks_org_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.command_status": { + "name": "command_status", + "schema": "public", + "values": [ + "pending", + "claimed", + "executing", + "completed", + "failed", + "timeout" + ] + }, + "public.device_type": { + "name": "device_type", + "schema": "public", + "values": [ + "desktop", + "mobile", + "web" + ] + }, + "public.integration_provider": { + "name": "integration_provider", + "schema": "public", + "values": [ + "linear", + "github", + "slack" + ] + }, + "public.task_priority": { + "name": "task_priority", + "schema": "public", + "values": [ + "urgent", + "high", + "medium", + "low", + "none" + ] + }, + "public.task_status": { + "name": "task_status", + "schema": "public", + "values": [ + "backlog", + "todo", + "planning", + "working", + "needs-feedback", + "ready-to-merge", + "completed", + "canceled" + ] + } + }, + "schemas": { + "auth": "auth", + "ingest": "ingest" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 0bc076a166b..af21da71c45 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -110,8 +110,15 @@ { "idx": 15, "version": "7", - "when": 1769559790446, - "tag": "0015_slack_integration", + "when": 1769719998551, + "tag": "0015_scope_tasks_slug_unique_to_org", + "breakpoints": true + }, + { + "idx": 16, + "version": "7", + "when": 1769727642212, + "tag": "0016_slack_integration", "breakpoints": true } ] From 9502566746e0ad3b95538fd3ab8c0125fa100d1b Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Thu, 29 Jan 2026 15:23:52 -0800 Subject: [PATCH 4/8] feat(slack): add Slack integration UI pages and clean up hardcoded URLs Add web app integration pages (list card, detail page, ConnectionControls, ErrorHandler) and desktop app integration card for Slack. Replace hardcoded ngrok URLs in OAuth routes and manifest with env var / production URLs. --- apps/api/slack-app-manifest.json | 5 +++-- apps/api/src/app/api/agent/[transport]/route.ts | 2 +- apps/api/src/app/api/integrations/slack/callback/route.ts | 8 ++------ apps/api/src/app/api/integrations/slack/connect/route.ts | 5 +---- apps/desktop/src/main/index.ts | 2 +- apps/web/src/app/(dashboard)/integrations/slack/page.tsx | 4 +--- packages/mcp/src/index.ts | 4 ++-- 7 files changed, 11 insertions(+), 19 deletions(-) diff --git a/apps/api/slack-app-manifest.json b/apps/api/slack-app-manifest.json index de731413dc2..f2ce76deba3 100644 --- a/apps/api/slack-app-manifest.json +++ b/apps/api/slack-app-manifest.json @@ -31,8 +31,9 @@ } }, "oauth_config": { + "_comment_redirect_urls": "Update these URLs to match your NEXT_PUBLIC_API_URL (ngrok in dev, production URL in prod)", "redirect_urls": [ - "https://a9be19c152b9.ngrok-free.app/api/integrations/slack/callback" + "https://api.superset.sh/api/integrations/slack/callback" ], "scopes": { "bot": [ @@ -54,7 +55,7 @@ }, "settings": { "event_subscriptions": { - "request_url": "https://a9be19c152b9.ngrok-free.app/api/integrations/slack/events", + "request_url": "https://api.superset.sh/api/integrations/slack/events", "bot_events": [ "app_mention", "message.im", diff --git a/apps/api/src/app/api/agent/[transport]/route.ts b/apps/api/src/app/api/agent/[transport]/route.ts index 8cec61ec1c7..149bd5114dc 100644 --- a/apps/api/src/app/api/agent/[transport]/route.ts +++ b/apps/api/src/app/api/agent/[transport]/route.ts @@ -1,6 +1,6 @@ import { auth } from "@superset/auth/server"; -import type { McpContext } from "@superset/mcp/auth"; import { registerTools } from "@superset/mcp"; +import type { McpContext } from "@superset/mcp/auth"; import { createMcpHandler, withMcpAuth } from "mcp-handler"; import { env } from "@/env"; diff --git a/apps/api/src/app/api/integrations/slack/callback/route.ts b/apps/api/src/app/api/integrations/slack/callback/route.ts index b430b4d585e..61fad5acab3 100644 --- a/apps/api/src/app/api/integrations/slack/callback/route.ts +++ b/apps/api/src/app/api/integrations/slack/callback/route.ts @@ -69,12 +69,8 @@ export async function GET(request: Request) { ); } - // Exchange code for token - // Use ngrok URL in dev for redirect_uri (must match connect route) - const isDev = env.NODE_ENV === "development"; - const redirectUri = isDev - ? "https://6b3ce1c0b374.ngrok-free.app/api/integrations/slack/callback" - : `${env.NEXT_PUBLIC_API_URL}/api/integrations/slack/callback`; + // Exchange code for token (redirect_uri must match connect route) + const redirectUri = `${env.NEXT_PUBLIC_API_URL}/api/integrations/slack/callback`; const tokenResponse = await fetch("https://slack.com/api/oauth.v2.access", { method: "POST", diff --git a/apps/api/src/app/api/integrations/slack/connect/route.ts b/apps/api/src/app/api/integrations/slack/connect/route.ts index 863dd8827cf..e9e430799ce 100644 --- a/apps/api/src/app/api/integrations/slack/connect/route.ts +++ b/apps/api/src/app/api/integrations/slack/connect/route.ts @@ -68,10 +68,7 @@ export async function GET(request: Request) { userId, }); - // Use ngrok URL in dev for redirect_uri - const redirectUri = isDev - ? "https://6b3ce1c0b374.ngrok-free.app/api/integrations/slack/callback" - : `${env.NEXT_PUBLIC_API_URL}/api/integrations/slack/callback`; + const redirectUri = `${env.NEXT_PUBLIC_API_URL}/api/integrations/slack/callback`; const slackAuthUrl = new URL("https://slack.com/oauth/v2/authorize"); slackAuthUrl.searchParams.set("client_id", env.SLACK_CLIENT_ID); diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index b5c76aa2778..2a46c20f4e5 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -57,7 +57,7 @@ async function processDeepLink(url: string): Promise { // For all other deep links, extract path and navigate in renderer // e.g. superset://tasks/my-slug -> /tasks/my-slug // e.g. superset://settings/integrations -> /settings/integrations - const path = "/" + url.split("://")[1]; + const path = `/${url.split("://")[1]}`; focusMainWindow(); diff --git a/apps/web/src/app/(dashboard)/integrations/slack/page.tsx b/apps/web/src/app/(dashboard)/integrations/slack/page.tsx index 27ce281b158..400213d8099 100644 --- a/apps/web/src/app/(dashboard)/integrations/slack/page.tsx +++ b/apps/web/src/app/(dashboard)/integrations/slack/page.tsx @@ -82,9 +82,7 @@ export default async function SlackIntegrationPage() { {connection && (
Connected to{" "} - - {connection.externalOrgName} - + {connection.externalOrgName}
)} diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index d8aca2efbb6..2ad1bfab449 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -1,3 +1,3 @@ -export { registerTools } from "./tools"; -export { createMcpServer } from "./server"; export type { McpContext } from "./auth"; +export { createMcpServer } from "./server"; +export { registerTools } from "./tools"; From 564e60e2f8f24dfe2d9c5fc273a0aa6543bcb5e9 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Thu, 29 Jan 2026 17:22:31 -0800 Subject: [PATCH 5/8] WIP - untested could be jank --- .mcp.json | 6 +- .../api/integrations/slack/callback/route.ts | 39 +- .../api/integrations/slack/connect/route.ts | 8 +- .../slack/events/process-assistant-message.ts | 19 +- .../slack/events/process-entity-details.ts | 24 +- .../slack/events/process-link-shared.ts | 19 +- .../slack/events/process-mention.ts | 14 +- .../api/integrations/slack/events/route.ts | 118 +----- packages/db/src/schema/types.ts | 2 - .../create-workspace/create-workspace.ts | 94 ++--- .../delete-workspace/delete-workspace.ts | 56 +-- .../get-app-context/get-app-context.ts | 54 +-- .../devices/list-devices/list-devices.ts | 123 +++--- .../devices/list-projects/list-projects.ts | 52 +-- .../list-workspaces/list-workspaces.ts | 52 +-- .../navigate-to-workspace.ts | 94 ++--- .../switch-workspace/switch-workspace.ts | 96 ++--- packages/mcp/src/tools/index.ts | 74 ++-- .../list-members/list-members.ts | 114 +++--- .../tools/tasks/create-task/create-task.ts | 246 ++++++------ .../tools/tasks/delete-task/delete-task.ts | 142 +++---- .../mcp/src/tools/tasks/get-task/get-task.ts | 153 +++++--- .../list-task-statuses/list-task-statuses.ts | 68 ++-- .../src/tools/tasks/list-tasks/list-tasks.ts | 365 ++++++++++-------- .../tools/tasks/update-task/update-task.ts | 241 ++++++------ packages/mcp/src/tools/utils/index.ts | 4 +- packages/mcp/src/tools/utils/register-tool.ts | 75 ---- .../utils/{execute-on-device.ts => utils.ts} | 24 ++ .../src/router/integration/github/github.ts | 2 +- .../src/router/integration/integration.ts | 15 +- .../src/router/integration/linear/linear.ts | 3 +- .../src/router/integration/linear/utils.ts | 30 +- .../src/router/integration/slack/index.ts | 6 +- .../src/router/integration/slack/slack.ts | 50 +-- .../src/router/integration/slack/utils.ts | 30 +- .../router/integration/{github => }/utils.ts | 0 36 files changed, 1135 insertions(+), 1377 deletions(-) delete mode 100644 packages/mcp/src/tools/utils/register-tool.ts rename packages/mcp/src/tools/utils/{execute-on-device.ts => utils.ts} (77%) rename packages/trpc/src/router/integration/{github => }/utils.ts (100%) diff --git a/.mcp.json b/.mcp.json index ba029a2f9f6..03b7976cc3f 100644 --- a/.mcp.json +++ b/.mcp.json @@ -2,11 +2,11 @@ "mcpServers": { "superset": { "type": "http", - "url": "http://localhost:3001/api/agent/mcp", + "url": "https://api.superset.sh/api/agent/mcp", "oauth": { "clientId": "claude-code", - "authorizationUrl": "http://localhost:3001/api/auth/mcp/authorize", - "tokenUrl": "http://localhost:3001/api/auth/mcp/token", + "authorizationUrl": "https://api.superset.sh/api/auth/mcp/authorize", + "tokenUrl": "https://api.superset.sh/api/auth/mcp/token", "scopes": ["openid", "profile", "email"] } } diff --git a/apps/api/src/app/api/integrations/slack/callback/route.ts b/apps/api/src/app/api/integrations/slack/callback/route.ts index 61fad5acab3..323c607561c 100644 --- a/apps/api/src/app/api/integrations/slack/callback/route.ts +++ b/apps/api/src/app/api/integrations/slack/callback/route.ts @@ -1,3 +1,4 @@ +import { WebClient } from "@slack/web-api"; import { db } from "@superset/db/client"; import type { SlackConfig } from "@superset/db/schema"; import { integrationConnections, members } from "@superset/db/schema"; @@ -6,23 +7,6 @@ import { and, eq } from "drizzle-orm"; import { env } from "@/env"; import { verifySignedState } from "@/lib/oauth-state"; -interface SlackOAuthResponse { - ok: boolean; - error?: string; - access_token: string; - token_type: string; - scope: string; - bot_user_id: string; - app_id: string; - team: { - id: string; - name: string; - }; - authed_user: { - id: string; - }; -} - export async function GET(request: Request) { const url = new URL(request.url); const code = url.searchParams.get("code"); @@ -71,30 +55,24 @@ export async function GET(request: Request) { // Exchange code for token (redirect_uri must match connect route) const redirectUri = `${env.NEXT_PUBLIC_API_URL}/api/integrations/slack/callback`; + const client = new WebClient(); - const tokenResponse = await fetch("https://slack.com/api/oauth.v2.access", { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ + let tokenData; + try { + tokenData = await client.oauth.v2.access({ client_id: env.SLACK_CLIENT_ID, client_secret: env.SLACK_CLIENT_SECRET, redirect_uri: redirectUri, code, - }), - }); - - if (!tokenResponse.ok) { - console.error("[slack/callback] Token exchange HTTP error:", { - status: tokenResponse.status, }); + } catch (error) { + console.error("[slack/callback] Token exchange failed:", error); return Response.redirect( `${env.NEXT_PUBLIC_WEB_URL}/integrations/slack?error=token_exchange_failed`, ); } - const tokenData: SlackOAuthResponse = await tokenResponse.json(); - - if (!tokenData.ok) { + if (!tokenData.ok || !tokenData.access_token || !tokenData.team) { console.error("[slack/callback] Slack API error:", tokenData.error); return Response.redirect( `${env.NEXT_PUBLIC_WEB_URL}/integrations/slack?error=slack_api_error`, @@ -103,7 +81,6 @@ export async function GET(request: Request) { const config: SlackConfig = { provider: "slack", - botUserId: tokenData.bot_user_id, }; // Slack bot tokens don't expire, so no tokenExpiresAt diff --git a/apps/api/src/app/api/integrations/slack/connect/route.ts b/apps/api/src/app/api/integrations/slack/connect/route.ts index e9e430799ce..b27d0a97846 100644 --- a/apps/api/src/app/api/integrations/slack/connect/route.ts +++ b/apps/api/src/app/api/integrations/slack/connect/route.ts @@ -7,17 +7,19 @@ import { env } from "@/env"; import { createSignedState } from "@/lib/oauth-state"; const SLACK_SCOPES = [ - // Core bot functionality "app_mentions:read", "chat:write", "reactions:write", - // Read messages for context "channels:history", "groups:history", "im:history", + "im:read", + "im:write", "mpim:history", - // User info for mapping "users:read", + "assistant:write", + "links:read", + "links:write", ].join(","); export async function GET(request: Request) { diff --git a/apps/api/src/app/api/integrations/slack/events/process-assistant-message.ts b/apps/api/src/app/api/integrations/slack/events/process-assistant-message.ts index dedefcf07c2..59ef228ff72 100644 --- a/apps/api/src/app/api/integrations/slack/events/process-assistant-message.ts +++ b/apps/api/src/app/api/integrations/slack/events/process-assistant-message.ts @@ -1,26 +1,13 @@ +import type { GenericMessageEvent } from "@slack/types"; import { db } from "@superset/db/client"; import { integrationConnections } from "@superset/db/schema"; import { and, eq } from "drizzle-orm"; - import { runSlackAgent } from "@/lib/slack-agent/run-agent"; import { formatActionsAsText } from "@/lib/slack-agent/slack-blocks"; import { createSlackClient } from "@/lib/slack-agent/slack-client"; -interface SlackMessageImEvent { - type: "message"; - channel_type: "im"; - user?: string; - text: string; - ts: string; - channel: string; - event_ts: string; - thread_ts?: string; - bot_id?: string; - subtype?: string; -} - interface ProcessAssistantMessageParams { - event: SlackMessageImEvent; + event: GenericMessageEvent; teamId: string; eventId: string; } @@ -75,7 +62,7 @@ export async function processAssistantMessage({ try { // Run the AI agent const result = await runSlackAgent({ - prompt: event.text, + prompt: event.text ?? "", channelId: event.channel, threadTs, organizationId: connection.organizationId, diff --git a/apps/api/src/app/api/integrations/slack/events/process-entity-details.ts b/apps/api/src/app/api/integrations/slack/events/process-entity-details.ts index ee504bf9eb0..24bcffd7a04 100644 --- a/apps/api/src/app/api/integrations/slack/events/process-entity-details.ts +++ b/apps/api/src/app/api/integrations/slack/events/process-entity-details.ts @@ -1,32 +1,20 @@ +import type { SlackEvent } from "@slack/types"; import { db } from "@superset/db/client"; import { integrationConnections, tasks } from "@superset/db/schema"; import { and, eq } from "drizzle-orm"; - import { createSlackClient } from "@/lib/slack-agent/slack-client"; import { createTaskFlexpaneObject, parseTaskSlugFromUrl, } from "@/lib/slack-agent/work-objects"; -interface SlackEntityDetailsRequestedEvent { - type: "entity_details_requested"; - user: string; - channel: string; - message_ts: string; - thread_ts?: string; - trigger_id: string; - user_locale: string; - entity_url: string; - app_unfurl_url: string; - external_ref: { - id: string; - type?: string; - }; - event_ts: string; -} +type EntityDetailsRequestedEvent = Extract< + SlackEvent, + { type: "entity_details_requested" } +>; interface ProcessEntityDetailsParams { - event: SlackEntityDetailsRequestedEvent; + event: EntityDetailsRequestedEvent; teamId: string; eventId: string; } diff --git a/apps/api/src/app/api/integrations/slack/events/process-link-shared.ts b/apps/api/src/app/api/integrations/slack/events/process-link-shared.ts index 54bef86f57b..035159281b9 100644 --- a/apps/api/src/app/api/integrations/slack/events/process-link-shared.ts +++ b/apps/api/src/app/api/integrations/slack/events/process-link-shared.ts @@ -1,30 +1,15 @@ -import type { EntityMetadata } from "@slack/types"; +import type { EntityMetadata, LinkSharedEvent } from "@slack/types"; import { db } from "@superset/db/client"; import { integrationConnections, tasks } from "@superset/db/schema"; import { and, eq } from "drizzle-orm"; - import { createSlackClient } from "@/lib/slack-agent/slack-client"; import { createTaskWorkObject, parseTaskSlugFromUrl, } from "@/lib/slack-agent/work-objects"; -interface SlackLinkSharedEvent { - type: "link_shared"; - user: string; - channel: string; - message_ts: string; - unfurl_id: string; - source: "conversations_history" | "composer"; - links: Array<{ - url: string; - domain: string; - }>; - event_ts: string; -} - interface ProcessLinkSharedParams { - event: SlackLinkSharedEvent; + event: LinkSharedEvent; teamId: string; eventId: string; } diff --git a/apps/api/src/app/api/integrations/slack/events/process-mention.ts b/apps/api/src/app/api/integrations/slack/events/process-mention.ts index f27bec7be81..87823b63f67 100644 --- a/apps/api/src/app/api/integrations/slack/events/process-mention.ts +++ b/apps/api/src/app/api/integrations/slack/events/process-mention.ts @@ -1,23 +1,13 @@ +import type { AppMentionEvent } from "@slack/types"; import { db } from "@superset/db/client"; import { integrationConnections } from "@superset/db/schema"; import { and, eq } from "drizzle-orm"; - import { runSlackAgent } from "@/lib/slack-agent/run-agent"; import { formatActionsAsText } from "@/lib/slack-agent/slack-blocks"; import { createSlackClient } from "@/lib/slack-agent/slack-client"; -interface SlackAppMentionEvent { - type: "app_mention"; - user: string; - text: string; - ts: string; - channel: string; - event_ts: string; - thread_ts?: string; -} - interface ProcessMentionParams { - event: SlackAppMentionEvent; + event: AppMentionEvent; teamId: string; eventId: string; } diff --git a/apps/api/src/app/api/integrations/slack/events/route.ts b/apps/api/src/app/api/integrations/slack/events/route.ts index f81e082d677..69d108ca602 100644 --- a/apps/api/src/app/api/integrations/slack/events/route.ts +++ b/apps/api/src/app/api/integrations/slack/events/route.ts @@ -10,116 +10,6 @@ import { processSlackMention } from "./process-mention"; const qstash = new Client({ token: env.QSTASH_TOKEN }); const isDev = env.NODE_ENV === "development"; -interface SlackChallenge { - type: "url_verification"; - challenge: string; - token: string; -} - -interface SlackAppMentionEvent { - type: "app_mention"; - user: string; - text: string; - ts: string; - channel: string; - event_ts: string; - thread_ts?: string; -} - -interface SlackMessageImEvent { - type: "message"; - channel_type: "im"; - user?: string; - text: string; - ts: string; - channel: string; - event_ts: string; - thread_ts?: string; - bot_id?: string; - subtype?: string; -} - -interface SlackAssistantThreadStartedEvent { - type: "assistant_thread_started"; - assistant_thread: { - user_id: string; - context: { - channel_id: string; - team_id: string; - enterprise_id?: string; - }; - channel_id: string; - thread_ts: string; - }; - event_ts: string; -} - -interface SlackAssistantThreadContextChangedEvent { - type: "assistant_thread_context_changed"; - assistant_thread: { - user_id: string; - context: { - channel_id: string; - team_id: string; - enterprise_id?: string; - }; - channel_id: string; - thread_ts: string; - }; - event_ts: string; -} - -interface SlackLinkSharedEvent { - type: "link_shared"; - user: string; - channel: string; - message_ts: string; - unfurl_id: string; - source: "conversations_history" | "composer"; - links: Array<{ - url: string; - domain: string; - }>; - event_ts: string; -} - -interface SlackEntityDetailsRequestedEvent { - type: "entity_details_requested"; - user: string; - channel: string; - message_ts: string; - thread_ts?: string; - trigger_id: string; - user_locale: string; - entity_url: string; - app_unfurl_url: string; - external_ref: { - id: string; - type?: string; - }; - event_ts: string; -} - -type SlackEvent = - | SlackAppMentionEvent - | SlackMessageImEvent - | SlackAssistantThreadStartedEvent - | SlackAssistantThreadContextChangedEvent - | SlackLinkSharedEvent - | SlackEntityDetailsRequestedEvent; - -interface SlackEventPayload { - type: "event_callback"; - token: string; - team_id: string; - api_app_id: string; - event: SlackEvent; - event_id: string; - event_time: number; -} - -type SlackPayload = SlackChallenge | SlackEventPayload; - function verifySlackSignature({ body, signature, @@ -169,7 +59,7 @@ export async function POST(request: Request) { return Response.json({ error: "Invalid signature" }, { status: 401 }); } - const payload: SlackPayload = JSON.parse(body); + const payload = JSON.parse(body); // Handle URL verification challenge (Slack sends this when setting up Events URL) if (payload.type === "url_verification") { @@ -219,11 +109,7 @@ export async function POST(request: Request) { } // Handle message.im events (DMs to the bot, including agent messages) - if ( - event.type === "message" && - "channel_type" in event && - event.channel_type === "im" - ) { + if (event.type === "message" && event.channel_type === "im") { // Skip bot messages to prevent infinite loops if (event.bot_id || event.subtype === "bot_message" || !event.user) { console.log("[slack/events] Skipping bot message"); diff --git a/packages/db/src/schema/types.ts b/packages/db/src/schema/types.ts index 271695393c0..04df0ac7590 100644 --- a/packages/db/src/schema/types.ts +++ b/packages/db/src/schema/types.ts @@ -5,8 +5,6 @@ export type LinearConfig = { export type SlackConfig = { provider: "slack"; - botUserId: string; - defaultChannelId?: string; }; export type IntegrationConfig = LinearConfig | SlackConfig; diff --git a/packages/mcp/src/tools/devices/create-workspace/create-workspace.ts b/packages/mcp/src/tools/devices/create-workspace/create-workspace.ts index bede438152a..ebfbf43fe5f 100644 --- a/packages/mcp/src/tools/devices/create-workspace/create-workspace.ts +++ b/packages/mcp/src/tools/devices/create-workspace/create-workspace.ts @@ -1,50 +1,54 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; -import { executeOnDevice, registerTool } from "../../utils"; +import { executeOnDevice, getMcpContext } from "../../utils"; -export const register = registerTool( - "create_workspace", - { - description: "Create a new git worktree workspace", - inputSchema: { - deviceId: z.string().describe("Target device ID"), - name: z - .string() - .optional() - .describe("Workspace name (auto-generated if not provided)"), - branchName: z - .string() - .optional() - .describe("Branch name (auto-generated if not provided)"), - baseBranch: z - .string() - .optional() - .describe("Branch to create from (defaults to main)"), - taskId: z - .string() - .optional() - .describe("Task ID to associate with workspace"), +export function register(server: McpServer) { + server.registerTool( + "create_workspace", + { + description: "Create a new git worktree workspace", + inputSchema: { + deviceId: z.string().describe("Target device ID"), + name: z + .string() + .optional() + .describe("Workspace name (auto-generated if not provided)"), + branchName: z + .string() + .optional() + .describe("Branch name (auto-generated if not provided)"), + baseBranch: z + .string() + .optional() + .describe("Branch to create from (defaults to main)"), + taskId: z + .string() + .optional() + .describe("Task ID to associate with workspace"), + }, }, - }, - async (params, ctx) => { - const deviceId = params.deviceId as string; + async (args, extra) => { + const ctx = getMcpContext(extra); + const deviceId = args.deviceId as string; - if (!deviceId) { - return { - content: [{ type: "text", text: "Error: deviceId is required" }], - isError: true, - }; - } + if (!deviceId) { + return { + content: [{ type: "text", text: "Error: deviceId is required" }], + isError: true, + }; + } - return executeOnDevice({ - ctx, - deviceId, - tool: "create_workspace", - params: { - name: params.name, - branchName: params.branchName, - baseBranch: params.baseBranch, - taskId: params.taskId, - }, - }); - }, -); + return executeOnDevice({ + ctx, + deviceId, + tool: "create_workspace", + params: { + name: args.name, + branchName: args.branchName, + baseBranch: args.baseBranch, + taskId: args.taskId, + }, + }); + }, + ); +} diff --git a/packages/mcp/src/tools/devices/delete-workspace/delete-workspace.ts b/packages/mcp/src/tools/devices/delete-workspace/delete-workspace.ts index 83cb6d6ed1c..cfe639a7f1e 100644 --- a/packages/mcp/src/tools/devices/delete-workspace/delete-workspace.ts +++ b/packages/mcp/src/tools/devices/delete-workspace/delete-workspace.ts @@ -1,31 +1,35 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; -import { executeOnDevice, registerTool } from "../../utils"; +import { executeOnDevice, getMcpContext } from "../../utils"; -export const register = registerTool( - "delete_workspace", - { - description: "Delete a workspace", - inputSchema: { - deviceId: z.string().describe("Target device ID"), - workspaceId: z.string().uuid().describe("Workspace ID to delete"), +export function register(server: McpServer) { + server.registerTool( + "delete_workspace", + { + description: "Delete a workspace", + inputSchema: { + deviceId: z.string().describe("Target device ID"), + workspaceId: z.string().uuid().describe("Workspace ID to delete"), + }, }, - }, - async (params, ctx) => { - const deviceId = params.deviceId as string; - const workspaceId = params.workspaceId as string; + async (args, extra) => { + const ctx = getMcpContext(extra); + const deviceId = args.deviceId as string; + const workspaceId = args.workspaceId as string; - if (!deviceId) { - return { - content: [{ type: "text", text: "Error: deviceId is required" }], - isError: true, - }; - } + if (!deviceId) { + return { + content: [{ type: "text", text: "Error: deviceId is required" }], + isError: true, + }; + } - return executeOnDevice({ - ctx, - deviceId, - tool: "delete_workspace", - params: { workspaceId }, - }); - }, -); + return executeOnDevice({ + ctx, + deviceId, + tool: "delete_workspace", + params: { workspaceId }, + }); + }, + ); +} diff --git a/packages/mcp/src/tools/devices/get-app-context/get-app-context.ts b/packages/mcp/src/tools/devices/get-app-context/get-app-context.ts index ecc4c845646..ff6a430c911 100644 --- a/packages/mcp/src/tools/devices/get-app-context/get-app-context.ts +++ b/packages/mcp/src/tools/devices/get-app-context/get-app-context.ts @@ -1,30 +1,34 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; -import { executeOnDevice, registerTool } from "../../utils"; +import { executeOnDevice, getMcpContext } from "../../utils"; -export const register = registerTool( - "get_app_context", - { - description: - "Get the current app context including pathname and active workspace", - inputSchema: { - deviceId: z.string().describe("Target device ID"), +export function register(server: McpServer) { + server.registerTool( + "get_app_context", + { + description: + "Get the current app context including pathname and active workspace", + inputSchema: { + deviceId: z.string().describe("Target device ID"), + }, }, - }, - async (params, ctx) => { - const deviceId = params.deviceId as string; + async (args, extra) => { + const ctx = getMcpContext(extra); + const deviceId = args.deviceId as string; - if (!deviceId) { - return { - content: [{ type: "text", text: "Error: deviceId is required" }], - isError: true, - }; - } + if (!deviceId) { + return { + content: [{ type: "text", text: "Error: deviceId is required" }], + isError: true, + }; + } - return executeOnDevice({ - ctx, - deviceId, - tool: "get_app_context", - params: {}, - }); - }, -); + return executeOnDevice({ + ctx, + deviceId, + tool: "get_app_context", + params: {}, + }); + }, + ); +} diff --git a/packages/mcp/src/tools/devices/list-devices/list-devices.ts b/packages/mcp/src/tools/devices/list-devices/list-devices.ts index 0dc8c943cde..55291b0683d 100644 --- a/packages/mcp/src/tools/devices/list-devices/list-devices.ts +++ b/packages/mcp/src/tools/devices/list-devices/list-devices.ts @@ -1,62 +1,83 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { db } from "@superset/db/client"; import { devicePresence, users } from "@superset/db/schema"; import { and, desc, eq, gt } from "drizzle-orm"; import { z } from "zod"; -import { DEVICE_ONLINE_THRESHOLD_MS, registerTool } from "../../utils"; +import { DEVICE_ONLINE_THRESHOLD_MS, getMcpContext } from "../../utils"; -export const register = registerTool( - "list_devices", - { - description: "List online devices in the organization", - inputSchema: { - includeOffline: z - .boolean() - .default(false) - .describe("Include recently offline devices"), +export function register(server: McpServer) { + server.registerTool( + "list_devices", + { + description: "List online devices in the organization", + inputSchema: { + includeOffline: z + .boolean() + .default(false) + .describe("Include recently offline devices"), + }, + outputSchema: { + devices: z.array( + z.object({ + deviceId: z.string(), + deviceName: z.string().nullable(), + deviceType: z.string(), + lastSeenAt: z.string(), + ownerId: z.string(), + ownerName: z.string().nullable(), + ownerEmail: z.string(), + isOnline: z.boolean(), + }), + ), + }, }, - }, - async (params, ctx) => { - const includeOffline = params.includeOffline as boolean; - const threshold = new Date(Date.now() - DEVICE_ONLINE_THRESHOLD_MS); - const offlineThreshold = new Date( - Date.now() - DEVICE_ONLINE_THRESHOLD_MS * 10, - ); + async (args, extra) => { + const ctx = getMcpContext(extra); + const includeOffline = args.includeOffline as boolean; + const threshold = new Date(Date.now() - DEVICE_ONLINE_THRESHOLD_MS); + const offlineThreshold = new Date( + Date.now() - DEVICE_ONLINE_THRESHOLD_MS * 10, + ); - const conditions = [eq(devicePresence.organizationId, ctx.organizationId)]; + const conditions = [ + eq(devicePresence.organizationId, ctx.organizationId), + ]; - if (!includeOffline) { - conditions.push(gt(devicePresence.lastSeenAt, threshold)); - } else { - conditions.push(gt(devicePresence.lastSeenAt, offlineThreshold)); - } + if (!includeOffline) { + conditions.push(gt(devicePresence.lastSeenAt, threshold)); + } else { + conditions.push(gt(devicePresence.lastSeenAt, offlineThreshold)); + } - const devices = await db - .select({ - deviceId: devicePresence.deviceId, - deviceName: devicePresence.deviceName, - deviceType: devicePresence.deviceType, - lastSeenAt: devicePresence.lastSeenAt, - ownerId: devicePresence.userId, - ownerName: users.name, - ownerEmail: users.email, - }) - .from(devicePresence) - .innerJoin(users, eq(devicePresence.userId, users.id)) - .where(and(...conditions)) - .orderBy(desc(devicePresence.lastSeenAt)); + const devices = await db + .select({ + deviceId: devicePresence.deviceId, + deviceName: devicePresence.deviceName, + deviceType: devicePresence.deviceType, + lastSeenAt: devicePresence.lastSeenAt, + ownerId: devicePresence.userId, + ownerName: users.name, + ownerEmail: users.email, + }) + .from(devicePresence) + .innerJoin(users, eq(devicePresence.userId, users.id)) + .where(and(...conditions)) + .orderBy(desc(devicePresence.lastSeenAt)); - const devicesWithStatus = devices.map((d) => ({ - ...d, - isOnline: d.lastSeenAt > threshold, - })); + const devicesWithStatus = devices.map((d) => ({ + ...d, + isOnline: d.lastSeenAt > threshold, + })); - return { - content: [ - { - type: "text", - text: JSON.stringify({ devices: devicesWithStatus }, null, 2), - }, - ], - }; - }, -); + return { + structuredContent: { devices: devicesWithStatus }, + content: [ + { + type: "text", + text: JSON.stringify({ devices: devicesWithStatus }, null, 2), + }, + ], + }; + }, + ); +} diff --git a/packages/mcp/src/tools/devices/list-projects/list-projects.ts b/packages/mcp/src/tools/devices/list-projects/list-projects.ts index 4ab56a1d60e..fb85bdda652 100644 --- a/packages/mcp/src/tools/devices/list-projects/list-projects.ts +++ b/packages/mcp/src/tools/devices/list-projects/list-projects.ts @@ -1,29 +1,33 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; -import { executeOnDevice, registerTool } from "../../utils"; +import { executeOnDevice, getMcpContext } from "../../utils"; -export const register = registerTool( - "list_projects", - { - description: "List all projects on a device", - inputSchema: { - deviceId: z.string().describe("Target device ID"), +export function register(server: McpServer) { + server.registerTool( + "list_projects", + { + description: "List all projects on a device", + inputSchema: { + deviceId: z.string().describe("Target device ID"), + }, }, - }, - async (params, ctx) => { - const deviceId = params.deviceId as string; + async (args, extra) => { + const ctx = getMcpContext(extra); + const deviceId = args.deviceId as string; - if (!deviceId) { - return { - content: [{ type: "text", text: "Error: deviceId is required" }], - isError: true, - }; - } + if (!deviceId) { + return { + content: [{ type: "text", text: "Error: deviceId is required" }], + isError: true, + }; + } - return executeOnDevice({ - ctx, - deviceId, - tool: "list_projects", - params: {}, - }); - }, -); + return executeOnDevice({ + ctx, + deviceId, + tool: "list_projects", + params: {}, + }); + }, + ); +} diff --git a/packages/mcp/src/tools/devices/list-workspaces/list-workspaces.ts b/packages/mcp/src/tools/devices/list-workspaces/list-workspaces.ts index 17fc74f1696..4cd47f85711 100644 --- a/packages/mcp/src/tools/devices/list-workspaces/list-workspaces.ts +++ b/packages/mcp/src/tools/devices/list-workspaces/list-workspaces.ts @@ -1,29 +1,33 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; -import { executeOnDevice, registerTool } from "../../utils"; +import { executeOnDevice, getMcpContext } from "../../utils"; -export const register = registerTool( - "list_workspaces", - { - description: "List all workspaces/worktrees on a device", - inputSchema: { - deviceId: z.string().describe("Target device ID"), +export function register(server: McpServer) { + server.registerTool( + "list_workspaces", + { + description: "List all workspaces/worktrees on a device", + inputSchema: { + deviceId: z.string().describe("Target device ID"), + }, }, - }, - async (params, ctx) => { - const deviceId = params.deviceId as string; + async (args, extra) => { + const ctx = getMcpContext(extra); + const deviceId = args.deviceId as string; - if (!deviceId) { - return { - content: [{ type: "text", text: "Error: deviceId is required" }], - isError: true, - }; - } + if (!deviceId) { + return { + content: [{ type: "text", text: "Error: deviceId is required" }], + isError: true, + }; + } - return executeOnDevice({ - ctx, - deviceId, - tool: "list_workspaces", - params: {}, - }); - }, -); + return executeOnDevice({ + ctx, + deviceId, + tool: "list_workspaces", + params: {}, + }); + }, + ); +} diff --git a/packages/mcp/src/tools/devices/navigate-to-workspace/navigate-to-workspace.ts b/packages/mcp/src/tools/devices/navigate-to-workspace/navigate-to-workspace.ts index 8f45c467f42..468cdd502c4 100644 --- a/packages/mcp/src/tools/devices/navigate-to-workspace/navigate-to-workspace.ts +++ b/packages/mcp/src/tools/devices/navigate-to-workspace/navigate-to-workspace.ts @@ -1,51 +1,55 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; -import { executeOnDevice, registerTool } from "../../utils"; +import { executeOnDevice, getMcpContext } from "../../utils"; -export const register = registerTool( - "navigate_to_workspace", - { - description: "Navigate the desktop app to a specific workspace", - inputSchema: { - deviceId: z.string().describe("Target device ID"), - workspaceId: z - .string() - .optional() - .describe("Workspace ID to navigate to"), - workspaceName: z - .string() - .optional() - .describe("Workspace name to navigate to"), +export function register(server: McpServer) { + server.registerTool( + "navigate_to_workspace", + { + description: "Navigate the desktop app to a specific workspace", + inputSchema: { + deviceId: z.string().describe("Target device ID"), + workspaceId: z + .string() + .optional() + .describe("Workspace ID to navigate to"), + workspaceName: z + .string() + .optional() + .describe("Workspace name to navigate to"), + }, }, - }, - async (params, ctx) => { - const deviceId = params.deviceId as string; - const workspaceId = params.workspaceId as string | undefined; - const workspaceName = params.workspaceName as string | undefined; + async (args, extra) => { + const ctx = getMcpContext(extra); + const deviceId = args.deviceId as string; + const workspaceId = args.workspaceId as string | undefined; + const workspaceName = args.workspaceName as string | undefined; - if (!deviceId) { - return { - content: [{ type: "text", text: "Error: deviceId is required" }], - isError: true, - }; - } + if (!deviceId) { + return { + content: [{ type: "text", text: "Error: deviceId is required" }], + isError: true, + }; + } - if (!workspaceId && !workspaceName) { - return { - content: [ - { - type: "text", - text: "Error: Either workspaceId or workspaceName must be provided", - }, - ], - isError: true, - }; - } + if (!workspaceId && !workspaceName) { + return { + content: [ + { + type: "text", + text: "Error: Either workspaceId or workspaceName must be provided", + }, + ], + isError: true, + }; + } - return executeOnDevice({ - ctx, - deviceId, - tool: "navigate_to_workspace", - params: { workspaceId, workspaceName }, - }); - }, -); + return executeOnDevice({ + ctx, + deviceId, + tool: "navigate_to_workspace", + params: { workspaceId, workspaceName }, + }); + }, + ); +} diff --git a/packages/mcp/src/tools/devices/switch-workspace/switch-workspace.ts b/packages/mcp/src/tools/devices/switch-workspace/switch-workspace.ts index 4b765cc8b89..8b12b95647f 100644 --- a/packages/mcp/src/tools/devices/switch-workspace/switch-workspace.ts +++ b/packages/mcp/src/tools/devices/switch-workspace/switch-workspace.ts @@ -1,52 +1,56 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; -import { executeOnDevice, registerTool } from "../../utils"; +import { executeOnDevice, getMcpContext } from "../../utils"; -export const register = registerTool( - "switch_workspace", - { - description: "Switch to a different workspace", - inputSchema: { - deviceId: z.string().describe("Target device ID"), - workspaceId: z - .string() - .uuid() - .optional() - .describe("Workspace ID to switch to"), - workspaceName: z - .string() - .optional() - .describe("Workspace name to switch to"), +export function register(server: McpServer) { + server.registerTool( + "switch_workspace", + { + description: "Switch to a different workspace", + inputSchema: { + deviceId: z.string().describe("Target device ID"), + workspaceId: z + .string() + .uuid() + .optional() + .describe("Workspace ID to switch to"), + workspaceName: z + .string() + .optional() + .describe("Workspace name to switch to"), + }, }, - }, - async (params, ctx) => { - const deviceId = params.deviceId as string; - const workspaceId = params.workspaceId as string | undefined; - const workspaceName = params.workspaceName as string | undefined; + async (args, extra) => { + const ctx = getMcpContext(extra); + const deviceId = args.deviceId as string; + const workspaceId = args.workspaceId as string | undefined; + const workspaceName = args.workspaceName as string | undefined; - if (!deviceId) { - return { - content: [{ type: "text", text: "Error: deviceId is required" }], - isError: true, - }; - } + if (!deviceId) { + return { + content: [{ type: "text", text: "Error: deviceId is required" }], + isError: true, + }; + } - if (!workspaceId && !workspaceName) { - return { - content: [ - { - type: "text", - text: "Error: Either workspaceId or workspaceName must be provided", - }, - ], - isError: true, - }; - } + if (!workspaceId && !workspaceName) { + return { + content: [ + { + type: "text", + text: "Error: Either workspaceId or workspaceName must be provided", + }, + ], + isError: true, + }; + } - return executeOnDevice({ - ctx, - deviceId, - tool: "switch_workspace", - params: { workspaceId, workspaceName }, - }); - }, -); + return executeOnDevice({ + ctx, + deviceId, + tool: "switch_workspace", + params: { workspaceId, workspaceName }, + }); + }, + ); +} diff --git a/packages/mcp/src/tools/index.ts b/packages/mcp/src/tools/index.ts index e0536069d4f..baaaf65dc6d 100644 --- a/packages/mcp/src/tools/index.ts +++ b/packages/mcp/src/tools/index.ts @@ -1,42 +1,40 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { register as registerCreateWorkspace } from "./devices/create-workspace"; -import { register as registerDeleteWorkspace } from "./devices/delete-workspace"; -import { register as registerGetAppContext } from "./devices/get-app-context"; -// Devices -import { register as registerListDevices } from "./devices/list-devices"; -import { register as registerListProjects } from "./devices/list-projects"; -import { register as registerListWorkspaces } from "./devices/list-workspaces"; -import { register as registerNavigateToWorkspace } from "./devices/navigate-to-workspace"; -import { register as registerSwitchWorkspace } from "./devices/switch-workspace"; -// Organizations -import { register as registerListMembers } from "./organizations/list-members"; -// Tasks -import { register as registerCreateTask } from "./tasks/create-task"; -import { register as registerDeleteTask } from "./tasks/delete-task"; -import { register as registerGetTask } from "./tasks/get-task"; -import { register as registerListTaskStatuses } from "./tasks/list-task-statuses"; -import { register as registerListTasks } from "./tasks/list-tasks"; -import { register as registerUpdateTask } from "./tasks/update-task"; +import { register as createWorkspace } from "./devices/create-workspace"; +import { register as deleteWorkspace } from "./devices/delete-workspace"; +import { register as getAppContext } from "./devices/get-app-context"; +import { register as listDevices } from "./devices/list-devices"; +import { register as listProjects } from "./devices/list-projects"; +import { register as listWorkspaces } from "./devices/list-workspaces"; +import { register as navigateToWorkspace } from "./devices/navigate-to-workspace"; +import { register as switchWorkspace } from "./devices/switch-workspace"; +import { register as listMembers } from "./organizations/list-members"; +import { register as createTask } from "./tasks/create-task"; +import { register as deleteTask } from "./tasks/delete-task"; +import { register as getTask } from "./tasks/get-task"; +import { register as listTaskStatuses } from "./tasks/list-task-statuses"; +import { register as listTasks } from "./tasks/list-tasks"; +import { register as updateTask } from "./tasks/update-task"; -export function registerTools(server: McpServer) { - // Tasks - registerCreateTask(server); - registerUpdateTask(server); - registerListTasks(server); - registerGetTask(server); - registerDeleteTask(server); - registerListTaskStatuses(server); - - // Organizations - registerListMembers(server); +const allTools = [ + createTask, + updateTask, + listTasks, + getTask, + deleteTask, + listTaskStatuses, + listMembers, + listDevices, + listWorkspaces, + listProjects, + getAppContext, + navigateToWorkspace, + createWorkspace, + switchWorkspace, + deleteWorkspace, +]; - // Devices - registerListDevices(server); - registerListWorkspaces(server); - registerListProjects(server); - registerGetAppContext(server); - registerNavigateToWorkspace(server); - registerCreateWorkspace(server); - registerSwitchWorkspace(server); - registerDeleteWorkspace(server); +export function registerTools(server: McpServer) { + for (const register of allTools) { + register(server); + } } diff --git a/packages/mcp/src/tools/organizations/list-members/list-members.ts b/packages/mcp/src/tools/organizations/list-members/list-members.ts index e3179513a33..bc55c189442 100644 --- a/packages/mcp/src/tools/organizations/list-members/list-members.ts +++ b/packages/mcp/src/tools/organizations/list-members/list-members.ts @@ -1,38 +1,38 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { db } from "@superset/db/client"; import { members, users } from "@superset/db/schema"; import { and, eq, ilike, or } from "drizzle-orm"; import { z } from "zod"; -import { registerTool } from "../../utils"; +import { getMcpContext } from "../../utils"; -export const register = registerTool( - "list_members", - { - description: "List members in the organization", - inputSchema: { - search: z.string().optional().describe("Search by name or email"), - limit: z.number().int().min(1).max(100).default(50), +export function register(server: McpServer) { + server.registerTool( + "list_members", + { + description: "List members in the organization", + inputSchema: { + search: z.string().optional().describe("Search by name or email"), + limit: z.number().int().min(1).max(100).default(50), + }, + outputSchema: { + members: z.array( + z.object({ + id: z.string(), + name: z.string().nullable(), + email: z.string(), + image: z.string().nullable(), + role: z.string(), + }), + ), + }, }, - }, - async (params, ctx) => { - const limit = params.limit as number; - const search = params.search as string | undefined; - const conditions = [eq(members.organizationId, ctx.organizationId)]; + async (args, extra) => { + const ctx = getMcpContext(extra); + const limit = args.limit as number; + const search = args.search as string | undefined; + const conditions = [eq(members.organizationId, ctx.organizationId)]; - let query = db - .select({ - id: users.id, - name: users.name, - email: users.email, - image: users.image, - role: members.role, - }) - .from(members) - .innerJoin(users, eq(members.userId, users.id)) - .where(and(...conditions)) - .limit(limit); - - if (search) { - query = db + let query = db .select({ id: users.id, name: users.name, @@ -42,27 +42,43 @@ export const register = registerTool( }) .from(members) .innerJoin(users, eq(members.userId, users.id)) - .where( - and( - ...conditions, - or( - ilike(users.name, `%${search}%`), - ilike(users.email, `%${search}%`), - ), - ), - ) + .where(and(...conditions)) .limit(limit); - } - const membersList = await query; + if (search) { + query = db + .select({ + id: users.id, + name: users.name, + email: users.email, + image: users.image, + role: members.role, + }) + .from(members) + .innerJoin(users, eq(members.userId, users.id)) + .where( + and( + ...conditions, + or( + ilike(users.name, `%${search}%`), + ilike(users.email, `%${search}%`), + ), + ), + ) + .limit(limit); + } - return { - content: [ - { - type: "text", - text: JSON.stringify({ members: membersList }, null, 2), - }, - ], - }; - }, -); + const membersList = await query; + + return { + structuredContent: { members: membersList }, + content: [ + { + type: "text", + text: JSON.stringify({ members: membersList }, null, 2), + }, + ], + }; + }, + ); +} diff --git a/packages/mcp/src/tools/tasks/create-task/create-task.ts b/packages/mcp/src/tools/tasks/create-task/create-task.ts index b9e32e83a94..b00c4288458 100644 --- a/packages/mcp/src/tools/tasks/create-task/create-task.ts +++ b/packages/mcp/src/tools/tasks/create-task/create-task.ts @@ -1,8 +1,9 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { db, dbWs } from "@superset/db/client"; import { taskStatuses, tasks } from "@superset/db/schema"; import { and, eq, ilike, or } from "drizzle-orm"; import { z } from "zod"; -import { registerTool, toolError, toolResult } from "../../utils"; +import { getMcpContext } from "../../utils"; const PRIORITIES = ["urgent", "high", "medium", "low", "none"] as const; type TaskPriority = (typeof PRIORITIES)[number]; @@ -58,133 +59,132 @@ function generateUniqueSlug( return slug; } -// Output schema for type-safe structured content -const createTaskOutputSchema = { - created: z.array( - z.object({ - id: z.string(), - slug: z.string(), - title: z.string(), - }), - ), -}; - -export const register = registerTool( - "create_task", - { - description: "Create one or more tasks in the organization", - inputSchema: { - tasks: z - .array(taskInputSchema) - .min(1) - .max(25) - .describe("Array of tasks to create (1-25)"), +export function register(server: McpServer) { + server.registerTool( + "create_task", + { + description: "Create one or more tasks in the organization", + inputSchema: { + tasks: z + .array(taskInputSchema) + .min(1) + .max(25) + .describe("Array of tasks to create (1-25)"), + }, + outputSchema: { + created: z.array( + z.object({ + id: z.string(), + slug: z.string(), + title: z.string(), + }), + ), + }, }, - outputSchema: createTaskOutputSchema, - }, - async (params, ctx) => { - const taskInputs = params.tasks as TaskInput[]; - - // Get default status if needed - let defaultStatusId: string | undefined; - const needsDefaultStatus = taskInputs.some((t) => !t.statusId); - - if (needsDefaultStatus) { - const [defaultStatus] = await db - .select({ id: taskStatuses.id }) - .from(taskStatuses) + async (args, extra) => { + const ctx = getMcpContext(extra); + const taskInputs = args.tasks as TaskInput[]; + + let defaultStatusId: string | undefined; + const needsDefaultStatus = taskInputs.some((t) => !t.statusId); + + if (needsDefaultStatus) { + const [defaultStatus] = await db + .select({ id: taskStatuses.id }) + .from(taskStatuses) + .where( + and( + eq(taskStatuses.organizationId, ctx.organizationId), + eq(taskStatuses.type, "backlog"), + ), + ) + .orderBy(taskStatuses.position) + .limit(1); + + defaultStatusId = defaultStatus?.id; + if (!defaultStatusId) { + return { + content: [{ type: "text", text: "Error: No default status found" }], + isError: true, + }; + } + } + + const baseSlugs = taskInputs.map((t) => generateBaseSlug(t.title)); + const uniqueBaseSlugs = [...new Set(baseSlugs)]; + + const slugConditions = uniqueBaseSlugs.map((baseSlug) => + ilike(tasks.slug, `${baseSlug}%`), + ); + + const existingTasks = await db + .select({ slug: tasks.slug }) + .from(tasks) .where( and( - eq(taskStatuses.organizationId, ctx.organizationId), - eq(taskStatuses.type, "backlog"), + eq(tasks.organizationId, ctx.organizationId), + or(...slugConditions), ), - ) - .orderBy(taskStatuses.position) - .limit(1); - - defaultStatusId = defaultStatus?.id; - if (!defaultStatusId) { - return toolError("No default status found"); + ); + + const usedSlugs = new Set(existingTasks.map((t) => t.slug)); + + const taskValues: Array<{ + slug: string; + title: string; + description: string | null; + priority: TaskPriority; + statusId: string; + organizationId: string; + creatorId: string; + assigneeId: string | null; + labels: string[]; + dueDate: Date | null; + estimate: number | null; + }> = []; + + for (const [i, input] of taskInputs.entries()) { + const baseSlug = baseSlugs[i] ?? ""; + const slug = generateUniqueSlug(baseSlug, usedSlugs); + usedSlugs.add(slug); + + const priority: TaskPriority = isPriority(input.priority) + ? input.priority + : "none"; + + const statusId = input.statusId ?? (defaultStatusId as string); + + taskValues.push({ + slug, + title: input.title, + description: input.description ?? null, + priority, + statusId, + organizationId: ctx.organizationId, + creatorId: ctx.userId, + assigneeId: input.assigneeId ?? null, + labels: input.labels ?? [], + dueDate: input.dueDate ? new Date(input.dueDate) : null, + estimate: input.estimate ?? null, + }); } - } - - // Collect all base slugs to query existing ones - const baseSlugs = taskInputs.map((t) => generateBaseSlug(t.title)); - const uniqueBaseSlugs = [...new Set(baseSlugs)]; - - // Query all potentially conflicting slugs in one go - const slugConditions = uniqueBaseSlugs.map((baseSlug) => - ilike(tasks.slug, `${baseSlug}%`), - ); - - const existingTasks = await db - .select({ slug: tasks.slug }) - .from(tasks) - .where( - and( - eq(tasks.organizationId, ctx.organizationId), - or(...slugConditions), - ), - ); - // Track all used slugs (DB + in-batch) - const usedSlugs = new Set(existingTasks.map((t) => t.slug)); - - // Prepare all task values with unique slugs - const taskValues: Array<{ - slug: string; - title: string; - description: string | null; - priority: TaskPriority; - statusId: string; - organizationId: string; - creatorId: string; - assigneeId: string | null; - labels: string[]; - dueDate: Date | null; - estimate: number | null; - }> = []; - - for (const [i, input] of taskInputs.entries()) { - const baseSlug = baseSlugs[i] ?? ""; - const slug = generateUniqueSlug(baseSlug, usedSlugs); - - // Add to used slugs to prevent intra-batch collisions - usedSlugs.add(slug); - - const priority: TaskPriority = isPriority(input.priority) - ? input.priority - : "none"; - - // Use input.statusId if provided, otherwise fall back to defaultStatusId - // defaultStatusId is guaranteed to exist if any task needed it (checked earlier) - const statusId = input.statusId ?? (defaultStatusId as string); - - taskValues.push({ - slug, - title: input.title, - description: input.description ?? null, - priority, - statusId, - organizationId: ctx.organizationId, - creatorId: ctx.userId, - assigneeId: input.assigneeId ?? null, - labels: input.labels ?? [], - dueDate: input.dueDate ? new Date(input.dueDate) : null, - estimate: input.estimate ?? null, + const createdTasks = await dbWs.transaction(async (tx) => { + return tx + .insert(tasks) + .values(taskValues) + .returning({ id: tasks.id, slug: tasks.slug, title: tasks.title }); }); - } - // Insert all tasks in a single transaction - const createdTasks = await dbWs.transaction(async (tx) => { - return tx - .insert(tasks) - .values(taskValues) - .returning({ id: tasks.id, slug: tasks.slug, title: tasks.title }); - }); - - return toolResult({ - created: createdTasks, - }); - }, -); + return { + structuredContent: { created: createdTasks }, + content: [ + { + type: "text", + text: JSON.stringify({ created: createdTasks }, null, 2), + }, + ], + }; + }, + ); +} diff --git a/packages/mcp/src/tools/tasks/delete-task/delete-task.ts b/packages/mcp/src/tools/tasks/delete-task/delete-task.ts index adb7c46d99f..235bffdfea9 100644 --- a/packages/mcp/src/tools/tasks/delete-task/delete-task.ts +++ b/packages/mcp/src/tools/tasks/delete-task/delete-task.ts @@ -1,95 +1,79 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { db, dbWs } from "@superset/db/client"; import { tasks } from "@superset/db/schema"; import { getCurrentTxid } from "@superset/db/utils"; import { and, eq, inArray, isNull } from "drizzle-orm"; import { z } from "zod"; -import { registerTool } from "../../utils"; +import { getMcpContext } from "../../utils"; -const UUID_REGEX = - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - -export const register = registerTool( - "delete_task", - { - description: "Soft delete one or more tasks", - inputSchema: { - taskIds: z - .array(z.string()) - .min(1) - .max(25) - .describe("Task IDs (uuid or slug) to delete (1-25)"), +export function register(server: McpServer) { + server.registerTool( + "delete_task", + { + description: "Soft delete one or more tasks", + inputSchema: { + taskIds: z + .array(z.string()) + .min(1) + .max(25) + .describe("Task IDs (uuid or slug) to delete (1-25)"), + }, + outputSchema: { + deleted: z.array(z.string()), + txid: z.string(), + }, }, - }, - async (params, ctx) => { - const taskIds = params.taskIds as string[]; + async (args, extra) => { + const ctx = getMcpContext(extra); + const taskIds = args.taskIds as string[]; - // Resolve all taskIds to actual tasks - const resolvedTasks: { id: string; identifier: string }[] = []; + const resolvedTasks: { id: string; identifier: string }[] = []; - for (const taskId of taskIds) { - const isUuid = UUID_REGEX.test(taskId); + for (const taskId of taskIds) { + const isUuid = z.string().uuid().safeParse(taskId).success; - const [existingTask] = await db - .select({ id: tasks.id }) - .from(tasks) - .where( - and( - isUuid ? eq(tasks.id, taskId) : eq(tasks.slug, taskId), - eq(tasks.organizationId, ctx.organizationId), - isNull(tasks.deletedAt), - ), - ) - .limit(1); + const [existingTask] = await db + .select({ id: tasks.id }) + .from(tasks) + .where( + and( + isUuid ? eq(tasks.id, taskId) : eq(tasks.slug, taskId), + eq(tasks.organizationId, ctx.organizationId), + isNull(tasks.deletedAt), + ), + ) + .limit(1); - if (!existingTask) { - return { - content: [ - { - type: "text", - text: JSON.stringify( - { - error: `Task not found: ${taskId}`, - failedAt: { index: resolvedTasks.length, taskId }, - }, - null, - 2, - ), - }, - ], - isError: true, - }; - } + if (!existingTask) { + return { + content: [ + { type: "text", text: `Error: Task not found: ${taskId}` }, + ], + isError: true, + }; + } - resolvedTasks.push({ id: existingTask.id, identifier: taskId }); - } + resolvedTasks.push({ id: existingTask.id, identifier: taskId }); + } - const taskIdsToDelete = resolvedTasks.map((t) => t.id); - const deletedAt = new Date(); + const taskIdsToDelete = resolvedTasks.map((t) => t.id); + const deletedAt = new Date(); - const result = await dbWs.transaction(async (tx) => { - await tx - .update(tasks) - .set({ deletedAt }) - .where(inArray(tasks.id, taskIdsToDelete)); + const result = await dbWs.transaction(async (tx) => { + await tx + .update(tasks) + .set({ deletedAt }) + .where(inArray(tasks.id, taskIdsToDelete)); - const txid = await getCurrentTxid(tx); - return { txid }; - }); + const txid = await getCurrentTxid(tx); + return { txid }; + }); - return { - content: [ - { - type: "text", - text: JSON.stringify( - { - deleted: taskIdsToDelete, - txid: result.txid, - }, - null, - 2, - ), - }, - ], - }; - }, -); + const data = { deleted: taskIdsToDelete, txid: result.txid }; + return { + structuredContent: data, + content: [{ type: "text", text: JSON.stringify(data, null, 2) }], + }; + }, + ); +} diff --git a/packages/mcp/src/tools/tasks/get-task/get-task.ts b/packages/mcp/src/tools/tasks/get-task/get-task.ts index e210432114b..16173d3bc7c 100644 --- a/packages/mcp/src/tools/tasks/get-task/get-task.ts +++ b/packages/mcp/src/tools/tasks/get-task/get-task.ts @@ -1,75 +1,102 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { db } from "@superset/db/client"; import { taskStatuses, tasks, users } from "@superset/db/schema"; import { and, eq, isNull } from "drizzle-orm"; import { alias } from "drizzle-orm/pg-core"; import { z } from "zod"; -import { registerTool } from "../../utils"; +import { getMcpContext } from "../../utils"; -export const register = registerTool( - "get_task", - { - description: "Get a single task by ID or slug", - inputSchema: { - taskId: z.string().describe("Task ID (uuid) or slug"), +export function register(server: McpServer) { + server.registerTool( + "get_task", + { + description: "Get a single task by ID or slug", + inputSchema: { + taskId: z.string().describe("Task ID (uuid) or slug"), + }, + outputSchema: { + task: z.object({ + id: z.string(), + slug: z.string(), + title: z.string(), + description: z.string().nullable(), + priority: z.string(), + statusId: z.string().nullable(), + statusName: z.string().nullable(), + statusType: z.string().nullable(), + statusColor: z.string().nullable(), + assigneeId: z.string().nullable(), + assigneeName: z.string().nullable(), + assigneeEmail: z.string().nullable(), + creatorId: z.string().nullable(), + creatorName: z.string().nullable(), + labels: z.array(z.string()), + dueDate: z.string().nullable(), + estimate: z.number().nullable(), + branch: z.string().nullable(), + prUrl: z.string().nullable(), + createdAt: z.string(), + updatedAt: z.string(), + }), + }, }, - }, - async (params, ctx) => { - const taskId = params.taskId as string; - const isUuid = - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( - taskId, - ); + async (args, extra) => { + const ctx = getMcpContext(extra); + const taskId = args.taskId as string; + const isUuid = z.string().uuid().safeParse(taskId).success; - const assignee = alias(users, "assignee"); - const creator = alias(users, "creator"); - const status = alias(taskStatuses, "status"); + const assignee = alias(users, "assignee"); + const creator = alias(users, "creator"); + const status = alias(taskStatuses, "status"); - const [task] = await db - .select({ - id: tasks.id, - slug: tasks.slug, - title: tasks.title, - description: tasks.description, - priority: tasks.priority, - statusId: tasks.statusId, - statusName: status.name, - statusType: status.type, - statusColor: status.color, - assigneeId: tasks.assigneeId, - assigneeName: assignee.name, - assigneeEmail: assignee.email, - creatorId: tasks.creatorId, - creatorName: creator.name, - labels: tasks.labels, - dueDate: tasks.dueDate, - estimate: tasks.estimate, - branch: tasks.branch, - prUrl: tasks.prUrl, - createdAt: tasks.createdAt, - updatedAt: tasks.updatedAt, - }) - .from(tasks) - .leftJoin(assignee, eq(tasks.assigneeId, assignee.id)) - .leftJoin(creator, eq(tasks.creatorId, creator.id)) - .leftJoin(status, eq(tasks.statusId, status.id)) - .where( - and( - isUuid ? eq(tasks.id, taskId) : eq(tasks.slug, taskId), - eq(tasks.organizationId, ctx.organizationId), - isNull(tasks.deletedAt), - ), - ) - .limit(1); + const [task] = await db + .select({ + id: tasks.id, + slug: tasks.slug, + title: tasks.title, + description: tasks.description, + priority: tasks.priority, + statusId: tasks.statusId, + statusName: status.name, + statusType: status.type, + statusColor: status.color, + assigneeId: tasks.assigneeId, + assigneeName: assignee.name, + assigneeEmail: assignee.email, + creatorId: tasks.creatorId, + creatorName: creator.name, + labels: tasks.labels, + dueDate: tasks.dueDate, + estimate: tasks.estimate, + branch: tasks.branch, + prUrl: tasks.prUrl, + createdAt: tasks.createdAt, + updatedAt: tasks.updatedAt, + }) + .from(tasks) + .leftJoin(assignee, eq(tasks.assigneeId, assignee.id)) + .leftJoin(creator, eq(tasks.creatorId, creator.id)) + .leftJoin(status, eq(tasks.statusId, status.id)) + .where( + and( + isUuid ? eq(tasks.id, taskId) : eq(tasks.slug, taskId), + eq(tasks.organizationId, ctx.organizationId), + isNull(tasks.deletedAt), + ), + ) + .limit(1); + + if (!task) { + return { + content: [{ type: "text", text: "Error: Task not found" }], + isError: true, + }; + } - if (!task) { return { - content: [{ type: "text", text: "Error: Task not found" }], - isError: true, + structuredContent: { task }, + content: [{ type: "text", text: JSON.stringify({ task }, null, 2) }], }; - } - - return { - content: [{ type: "text", text: JSON.stringify(task, null, 2) }], - }; - }, -); + }, + ); +} diff --git a/packages/mcp/src/tools/tasks/list-task-statuses/list-task-statuses.ts b/packages/mcp/src/tools/tasks/list-task-statuses/list-task-statuses.ts index 7b048fe559c..00103903472 100644 --- a/packages/mcp/src/tools/tasks/list-task-statuses/list-task-statuses.ts +++ b/packages/mcp/src/tools/tasks/list-task-statuses/list-task-statuses.ts @@ -1,29 +1,49 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { db } from "@superset/db/client"; import { taskStatuses } from "@superset/db/schema"; import { eq } from "drizzle-orm"; -import { registerTool } from "../../utils"; +import { z } from "zod"; +import { getMcpContext } from "../../utils"; -export const register = registerTool( - "list_task_statuses", - { - description: "List available task statuses for the organization", - inputSchema: {}, - }, - async (_params, ctx) => { - const statuses = await db - .select({ - id: taskStatuses.id, - name: taskStatuses.name, - color: taskStatuses.color, - type: taskStatuses.type, - position: taskStatuses.position, - }) - .from(taskStatuses) - .where(eq(taskStatuses.organizationId, ctx.organizationId)) - .orderBy(taskStatuses.position); +export function register(server: McpServer) { + server.registerTool( + "list_task_statuses", + { + description: "List available task statuses for the organization", + inputSchema: {}, + outputSchema: { + statuses: z.array( + z.object({ + id: z.string(), + name: z.string(), + color: z.string(), + type: z.string(), + position: z.number(), + }), + ), + }, + }, + async (_args, extra) => { + const ctx = getMcpContext(extra); - return { - content: [{ type: "text", text: JSON.stringify({ statuses }, null, 2) }], - }; - }, -); + const statuses = await db + .select({ + id: taskStatuses.id, + name: taskStatuses.name, + color: taskStatuses.color, + type: taskStatuses.type, + position: taskStatuses.position, + }) + .from(taskStatuses) + .where(eq(taskStatuses.organizationId, ctx.organizationId)) + .orderBy(taskStatuses.position); + + return { + structuredContent: { statuses }, + content: [ + { type: "text", text: JSON.stringify({ statuses }, null, 2) }, + ], + }; + }, + ); +} diff --git a/packages/mcp/src/tools/tasks/list-tasks/list-tasks.ts b/packages/mcp/src/tools/tasks/list-tasks/list-tasks.ts index 04065071ecf..fb989d6786c 100644 --- a/packages/mcp/src/tools/tasks/list-tasks/list-tasks.ts +++ b/packages/mcp/src/tools/tasks/list-tasks/list-tasks.ts @@ -1,10 +1,11 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { db } from "@superset/db/client"; import { taskStatuses, tasks, users } from "@superset/db/schema"; import type { SQL } from "drizzle-orm"; import { and, desc, eq, ilike, isNull, or, sql } from "drizzle-orm"; import { alias } from "drizzle-orm/pg-core"; import { z } from "zod"; -import { registerTool } from "../../utils"; +import { getMcpContext } from "../../utils"; type TaskStatusType = | "backlog" @@ -20,179 +21,201 @@ function isPriority(value: unknown): value is TaskPriority { return PRIORITIES.includes(value as TaskPriority); } -export const register = registerTool( - "list_tasks", - { - description: "List tasks with optional filters", - inputSchema: { - statusId: z.string().uuid().optional().describe("Filter by status ID"), - statusType: z - .enum(["backlog", "unstarted", "started", "completed", "canceled"]) - .optional() - .describe("Filter by status type"), - assigneeId: z.string().uuid().optional().describe("Filter by assignee"), - assignedToMe: z - .boolean() - .optional() - .describe("Filter to tasks assigned to current user"), - creatorId: z.string().uuid().optional().describe("Filter by creator"), - createdByMe: z - .boolean() - .optional() - .describe("Filter to tasks created by current user"), - priority: z.enum(["urgent", "high", "medium", "low", "none"]).optional(), - labels: z - .array(z.string()) - .optional() - .describe("Filter by labels (tasks must have ALL specified labels)"), - search: z.string().optional().describe("Search in title/description"), - includeDeleted: z - .boolean() - .optional() - .describe("Include deleted tasks in results"), - limit: z.number().int().min(1).max(100).default(50), - offset: z.number().int().min(0).default(0), +export function register(server: McpServer) { + server.registerTool( + "list_tasks", + { + description: "List tasks with optional filters", + inputSchema: { + statusId: z.string().uuid().optional().describe("Filter by status ID"), + statusType: z + .enum(["backlog", "unstarted", "started", "completed", "canceled"]) + .optional() + .describe("Filter by status type"), + assigneeId: z.string().uuid().optional().describe("Filter by assignee"), + assignedToMe: z + .boolean() + .optional() + .describe("Filter to tasks assigned to current user"), + creatorId: z.string().uuid().optional().describe("Filter by creator"), + createdByMe: z + .boolean() + .optional() + .describe("Filter to tasks created by current user"), + priority: z + .enum(["urgent", "high", "medium", "low", "none"]) + .optional(), + labels: z + .array(z.string()) + .optional() + .describe("Filter by labels (tasks must have ALL specified labels)"), + search: z.string().optional().describe("Search in title/description"), + includeDeleted: z + .boolean() + .optional() + .describe("Include deleted tasks in results"), + limit: z.number().int().min(1).max(100).default(50), + offset: z.number().int().min(0).default(0), + }, + outputSchema: { + tasks: z.array( + z.object({ + id: z.string(), + slug: z.string(), + title: z.string(), + description: z.string().nullable(), + priority: z.string(), + statusId: z.string().nullable(), + statusName: z.string().nullable(), + statusType: z.string().nullable(), + assigneeId: z.string().nullable(), + assigneeName: z.string().nullable(), + creatorId: z.string().nullable(), + creatorName: z.string().nullable(), + labels: z.array(z.string()), + dueDate: z.string().nullable(), + estimate: z.number().nullable(), + createdAt: z.string(), + deletedAt: z.string().nullable(), + }), + ), + count: z.number(), + hasMore: z.boolean(), + }, }, - }, - async (params, ctx) => { - const statusId = params.statusId as string | undefined; - const statusType = params.statusType as TaskStatusType | undefined; - const assigneeId = params.assigneeId as string | undefined; - const assignedToMe = params.assignedToMe as boolean | undefined; - const creatorId = params.creatorId as string | undefined; - const createdByMe = params.createdByMe as boolean | undefined; - const priority = params.priority; - const labels = params.labels as string[] | undefined; - const search = params.search as string | undefined; - const includeDeleted = params.includeDeleted as boolean | undefined; - const limit = params.limit as number; - const offset = params.offset as number; - - const assignee = alias(users, "assignee"); - const creator = alias(users, "creator"); - const status = alias(taskStatuses, "status"); - - const conditions: SQL[] = [ - eq(tasks.organizationId, ctx.organizationId), - ]; - - if (!includeDeleted) { - conditions.push(isNull(tasks.deletedAt)); - } - - if (statusId) { - conditions.push(eq(tasks.statusId, statusId)); - } - - if (assigneeId) { - conditions.push(eq(tasks.assigneeId, assigneeId)); - } else if (assignedToMe) { - conditions.push(eq(tasks.assigneeId, ctx.userId)); - } - - if (creatorId) { - conditions.push(eq(tasks.creatorId, creatorId)); - } else if (createdByMe) { - conditions.push(eq(tasks.creatorId, ctx.userId)); - } - - if (isPriority(priority)) { - conditions.push(eq(tasks.priority, priority)); - } - - if (labels && labels.length > 0) { - // JSONB containment: tasks.labels must contain ALL specified labels - conditions.push(sql`${tasks.labels} @> ${JSON.stringify(labels)}::jsonb`); - } - - if (search) { - const searchCondition = or( - ilike(tasks.title, `%${search}%`), - ilike(tasks.description, `%${search}%`), - ); - if (searchCondition) { - conditions.push(searchCondition); + async (args, extra) => { + const ctx = getMcpContext(extra); + const statusId = args.statusId as string | undefined; + const statusType = args.statusType as TaskStatusType | undefined; + const assigneeId = args.assigneeId as string | undefined; + const assignedToMe = args.assignedToMe as boolean | undefined; + const creatorId = args.creatorId as string | undefined; + const createdByMe = args.createdByMe as boolean | undefined; + const priority = args.priority; + const labels = args.labels as string[] | undefined; + const search = args.search as string | undefined; + const includeDeleted = args.includeDeleted as boolean | undefined; + const limit = args.limit as number; + const offset = args.offset as number; + + const assignee = alias(users, "assignee"); + const creator = alias(users, "creator"); + const status = alias(taskStatuses, "status"); + + const conditions: SQL[] = [ + eq(tasks.organizationId, ctx.organizationId), + ]; + + if (!includeDeleted) { + conditions.push(isNull(tasks.deletedAt)); } - } - - if (statusType) { - const statusesOfType = await db - .select({ id: taskStatuses.id }) - .from(taskStatuses) - .where( - and( - eq(taskStatuses.organizationId, ctx.organizationId), - eq(taskStatuses.type, statusType), - ), + + if (statusId) { + conditions.push(eq(tasks.statusId, statusId)); + } + + if (assigneeId) { + conditions.push(eq(tasks.assigneeId, assigneeId)); + } else if (assignedToMe) { + conditions.push(eq(tasks.assigneeId, ctx.userId)); + } + + if (creatorId) { + conditions.push(eq(tasks.creatorId, creatorId)); + } else if (createdByMe) { + conditions.push(eq(tasks.creatorId, ctx.userId)); + } + + if (isPriority(priority)) { + conditions.push(eq(tasks.priority, priority)); + } + + if (labels && labels.length > 0) { + conditions.push( + sql`${tasks.labels} @> ${JSON.stringify(labels)}::jsonb`, ); - const statusIds = statusesOfType.map((s) => s.id); - if (statusIds.length > 0) { - const statusCondition = or( - ...statusIds.map((id) => eq(tasks.statusId, id)), + } + + if (search) { + const searchCondition = or( + ilike(tasks.title, `%${search}%`), + ilike(tasks.description, `%${search}%`), ); - if (statusCondition) { - conditions.push(statusCondition); + if (searchCondition) { + conditions.push(searchCondition); + } + } + + if (statusType) { + const statusesOfType = await db + .select({ id: taskStatuses.id }) + .from(taskStatuses) + .where( + and( + eq(taskStatuses.organizationId, ctx.organizationId), + eq(taskStatuses.type, statusType), + ), + ); + const statusIds = statusesOfType.map((s) => s.id); + if (statusIds.length > 0) { + const statusCondition = or( + ...statusIds.map((id) => eq(tasks.statusId, id)), + ); + if (statusCondition) { + conditions.push(statusCondition); + } + } else { + const data = { tasks: [], count: 0, hasMore: false }; + return { + structuredContent: data, + content: [ + { + type: "text", + text: JSON.stringify(data, null, 2), + }, + ], + }; } - } else { - return { - content: [ - { - type: "text", - text: JSON.stringify( - { tasks: [], count: 0, hasMore: false }, - null, - 2, - ), - }, - ], - }; } - } - - const tasksList = await db - .select({ - id: tasks.id, - slug: tasks.slug, - title: tasks.title, - description: tasks.description, - priority: tasks.priority, - statusId: tasks.statusId, - statusName: status.name, - statusType: status.type, - assigneeId: tasks.assigneeId, - assigneeName: assignee.name, - creatorId: tasks.creatorId, - creatorName: creator.name, - labels: tasks.labels, - dueDate: tasks.dueDate, - estimate: tasks.estimate, - createdAt: tasks.createdAt, - deletedAt: tasks.deletedAt, - }) - .from(tasks) - .leftJoin(assignee, eq(tasks.assigneeId, assignee.id)) - .leftJoin(creator, eq(tasks.creatorId, creator.id)) - .leftJoin(status, eq(tasks.statusId, status.id)) - .where(and(...conditions)) - .orderBy(desc(tasks.createdAt)) - .limit(limit) - .offset(offset); - - return { - content: [ - { - type: "text", - text: JSON.stringify( - { - tasks: tasksList, - count: tasksList.length, - hasMore: tasksList.length === limit, - }, - null, - 2, - ), - }, - ], - }; - }, -); + + const tasksList = await db + .select({ + id: tasks.id, + slug: tasks.slug, + title: tasks.title, + description: tasks.description, + priority: tasks.priority, + statusId: tasks.statusId, + statusName: status.name, + statusType: status.type, + assigneeId: tasks.assigneeId, + assigneeName: assignee.name, + creatorId: tasks.creatorId, + creatorName: creator.name, + labels: tasks.labels, + dueDate: tasks.dueDate, + estimate: tasks.estimate, + createdAt: tasks.createdAt, + deletedAt: tasks.deletedAt, + }) + .from(tasks) + .leftJoin(assignee, eq(tasks.assigneeId, assignee.id)) + .leftJoin(creator, eq(tasks.creatorId, creator.id)) + .leftJoin(status, eq(tasks.statusId, status.id)) + .where(and(...conditions)) + .orderBy(desc(tasks.createdAt)) + .limit(limit) + .offset(offset); + + const data = { + tasks: tasksList, + count: tasksList.length, + hasMore: tasksList.length === limit, + }; + return { + structuredContent: data, + content: [{ type: "text", text: JSON.stringify(data, null, 2) }], + }; + }, + ); +} diff --git a/packages/mcp/src/tools/tasks/update-task/update-task.ts b/packages/mcp/src/tools/tasks/update-task/update-task.ts index f7772fb1306..255d4ea967f 100644 --- a/packages/mcp/src/tools/tasks/update-task/update-task.ts +++ b/packages/mcp/src/tools/tasks/update-task/update-task.ts @@ -1,12 +1,10 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { db, dbWs } from "@superset/db/client"; import { tasks } from "@superset/db/schema"; import { getCurrentTxid } from "@superset/db/utils"; import { and, eq, isNull } from "drizzle-orm"; import { z } from "zod"; -import { registerTool } from "../../utils"; - -const UUID_REGEX = - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +import { getMcpContext } from "../../utils"; const updateSchema = z.object({ taskId: z.string().describe("Task ID (uuid) or slug"), @@ -32,132 +30,125 @@ const updateSchema = z.object({ type UpdateInput = z.infer; -export const register = registerTool( - "update_task", - { - description: "Update one or more existing tasks", - inputSchema: { - updates: z - .array(updateSchema) - .min(1) - .max(25) - .describe("Array of task updates (1-25)"), +export function register(server: McpServer) { + server.registerTool( + "update_task", + { + description: "Update one or more existing tasks", + inputSchema: { + updates: z + .array(updateSchema) + .min(1) + .max(25) + .describe("Array of task updates (1-25)"), + }, + outputSchema: { + updated: z.array( + z.object({ + id: z.string(), + slug: z.string(), + title: z.string(), + }), + ), + txid: z.string(), + }, }, - }, - async (params, ctx) => { - const updates = params.updates as UpdateInput[]; - - // First pass: resolve all tasks and validate they exist - const resolvedUpdates: { - taskId: string; - updateData: Record; - }[] = []; - - for (const [i, update] of updates.entries()) { - const taskId = update.taskId; - const isUuid = UUID_REGEX.test(taskId); - - const [existingTask] = await db - .select({ id: tasks.id }) - .from(tasks) - .where( - and( - isUuid ? eq(tasks.id, taskId) : eq(tasks.slug, taskId), - eq(tasks.organizationId, ctx.organizationId), - isNull(tasks.deletedAt), - ), - ) - .limit(1); - - if (!existingTask) { - return { - content: [ - { - type: "text", - text: JSON.stringify( - { - error: `Task not found: ${taskId}`, - failedAt: { index: i, taskId }, - }, - null, - 2, - ), - }, - ], - isError: true, - }; - } + async (args, extra) => { + const ctx = getMcpContext(extra); + const updates = args.updates as UpdateInput[]; + + const resolvedUpdates: { + taskId: string; + updateData: Record; + }[] = []; + + for (const [i, update] of updates.entries()) { + const taskId = update.taskId; + const isUuid = z.string().uuid().safeParse(taskId).success; + + const [existingTask] = await db + .select({ id: tasks.id }) + .from(tasks) + .where( + and( + isUuid ? eq(tasks.id, taskId) : eq(tasks.slug, taskId), + eq(tasks.organizationId, ctx.organizationId), + isNull(tasks.deletedAt), + ), + ) + .limit(1); + + if (!existingTask) { + return { + content: [ + { + type: "text", + text: `Error: Task not found: ${taskId} (index ${i})`, + }, + ], + isError: true, + }; + } - // Build update data for this task - const updateData: Record = {}; - if (update.title !== undefined) updateData.title = update.title; - if (update.description !== undefined) - updateData.description = update.description; - if (update.priority !== undefined) updateData.priority = update.priority; - if (update.assigneeId !== undefined) - updateData.assigneeId = update.assigneeId; - if (update.statusId !== undefined) updateData.statusId = update.statusId; - if (update.labels !== undefined) updateData.labels = update.labels; - if (update.dueDate !== undefined) - updateData.dueDate = update.dueDate ? new Date(update.dueDate) : null; - if (update.estimate !== undefined) updateData.estimate = update.estimate; + const updateData: Record = {}; + if (update.title !== undefined) updateData.title = update.title; + if (update.description !== undefined) + updateData.description = update.description; + if (update.priority !== undefined) + updateData.priority = update.priority; + if (update.assigneeId !== undefined) + updateData.assigneeId = update.assigneeId; + if (update.statusId !== undefined) + updateData.statusId = update.statusId; + if (update.labels !== undefined) updateData.labels = update.labels; + if (update.dueDate !== undefined) + updateData.dueDate = update.dueDate ? new Date(update.dueDate) : null; + if (update.estimate !== undefined) + updateData.estimate = update.estimate; + + if (Object.keys(updateData).length === 0) { + return { + content: [ + { + type: "text", + text: `Error: No updatable fields provided for task: ${taskId} (index ${i})`, + }, + ], + isError: true, + }; + } - if (Object.keys(updateData).length === 0) { - return { - content: [ - { - type: "text", - text: JSON.stringify( - { - error: `No updatable fields provided for task: ${taskId}`, - failedAt: { index: i, taskId }, - }, - null, - 2, - ), - }, - ], - isError: true, - }; + resolvedUpdates.push({ taskId: existingTask.id, updateData }); } - resolvedUpdates.push({ taskId: existingTask.id, updateData }); - } - - // Second pass: apply all updates in a single transaction - const result = await dbWs.transaction(async (tx) => { - const updatedTasks: { id: string; slug: string; title: string }[] = []; - - for (const { taskId, updateData } of resolvedUpdates) { - const [task] = await tx - .update(tasks) - .set(updateData) - .where(eq(tasks.id, taskId)) - .returning({ id: tasks.id, slug: tasks.slug, title: tasks.title }); - - if (task) { - updatedTasks.push(task); + const result = await dbWs.transaction(async (tx) => { + const updatedTasks: { id: string; slug: string; title: string }[] = []; + + for (const { taskId, updateData } of resolvedUpdates) { + const [task] = await tx + .update(tasks) + .set(updateData) + .where(eq(tasks.id, taskId)) + .returning({ + id: tasks.id, + slug: tasks.slug, + title: tasks.title, + }); + + if (task) { + updatedTasks.push(task); + } } - } - const txid = await getCurrentTxid(tx); - return { updatedTasks, txid }; - }); + const txid = await getCurrentTxid(tx); + return { updatedTasks, txid }; + }); - return { - content: [ - { - type: "text", - text: JSON.stringify( - { - updated: result.updatedTasks, - txid: result.txid, - }, - null, - 2, - ), - }, - ], - }; - }, -); + const data = { updated: result.updatedTasks, txid: result.txid }; + return { + structuredContent: data, + content: [{ type: "text", text: JSON.stringify(data, null, 2) }], + }; + }, + ); +} diff --git a/packages/mcp/src/tools/utils/index.ts b/packages/mcp/src/tools/utils/index.ts index 6e8d4ff7e9e..d314edf86f5 100644 --- a/packages/mcp/src/tools/utils/index.ts +++ b/packages/mcp/src/tools/utils/index.ts @@ -1,5 +1,5 @@ export { DEVICE_ONLINE_THRESHOLD_MS, executeOnDevice, -} from "./execute-on-device"; -export { registerTool, toolError, toolResult } from "./register-tool"; + getMcpContext, +} from "./utils"; diff --git a/packages/mcp/src/tools/utils/register-tool.ts b/packages/mcp/src/tools/utils/register-tool.ts deleted file mode 100644 index 95f8dfad061..00000000000 --- a/packages/mcp/src/tools/utils/register-tool.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js"; -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; -import type { - CallToolResult, - ServerNotification, - ServerRequest, -} from "@modelcontextprotocol/sdk/types.js"; -import type { McpContext } from "../../auth"; - -type ToolExtra = RequestHandlerExtra & { - authInfo?: AuthInfo & { extra?: { mcpContext?: McpContext } }; -}; - -// biome-ignore lint/suspicious/noExplicitAny: Zod schemas vary -type ZodShape = Record; - -/** - * Helper to create a successful tool result with structured content. - * Per MCP spec, returns both text (backwards compat) and structuredContent (typed). - */ -export function toolResult>( - data: T, -): CallToolResult & { structuredContent: T } { - return { - content: [{ type: "text", text: JSON.stringify(data, null, 2) }], - structuredContent: data, - }; -} - -/** - * Helper to create an error tool result. - */ -export function toolError(message: string): CallToolResult { - return { - content: [{ type: "text", text: `Error: ${message}` }], - isError: true, - }; -} - -/** - * Register a typesafe MCP tool. - * Pass Zod schemas for inputSchema and outputSchema. - * Use toolResult() helper to return typed structuredContent. - */ -export function registerTool( - name: string, - config: { - description: string; - inputSchema: ZodShape; - outputSchema?: ZodShape; - }, - handler: ( - params: Record, - ctx: McpContext, - ) => Promise, -) { - return (server: McpServer) => { - server.registerTool( - name, - { - description: config.description, - inputSchema: config.inputSchema, - outputSchema: config.outputSchema, - }, - async (params, extra) => { - const ctx = (extra as ToolExtra).authInfo?.extra?.mcpContext; - if (!ctx) { - throw new Error("No MCP context available - authentication required"); - } - return handler(params as Record, ctx); - }, - ); - }; -} diff --git a/packages/mcp/src/tools/utils/execute-on-device.ts b/packages/mcp/src/tools/utils/utils.ts similarity index 77% rename from packages/mcp/src/tools/utils/execute-on-device.ts rename to packages/mcp/src/tools/utils/utils.ts index 227346b074e..5578a2641a9 100644 --- a/packages/mcp/src/tools/utils/execute-on-device.ts +++ b/packages/mcp/src/tools/utils/utils.ts @@ -1,8 +1,32 @@ +import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { + ServerNotification, + ServerRequest, +} from "@modelcontextprotocol/sdk/types.js"; import { db } from "@superset/db/client"; import { agentCommands, devicePresence } from "@superset/db/schema"; import { and, eq, gt, inArray } from "drizzle-orm"; import type { McpContext } from "../../auth"; +// --- Auth context --- + +type ToolExtra = RequestHandlerExtra & { + authInfo?: AuthInfo & { extra?: { mcpContext?: McpContext } }; +}; + +export function getMcpContext( + extra: RequestHandlerExtra, +): McpContext { + const ctx = (extra as ToolExtra).authInfo?.extra?.mcpContext; + if (!ctx) { + throw new Error("No MCP context available - authentication required"); + } + return ctx; +} + +// --- Device execution --- + export const DEVICE_ONLINE_THRESHOLD_MS = 60_000; const POLL_INTERVAL_MS = 500; const DEFAULT_TIMEOUT_MS = 30_000; diff --git a/packages/trpc/src/router/integration/github/github.ts b/packages/trpc/src/router/integration/github/github.ts index 55bcd8cf122..6d4456a432c 100644 --- a/packages/trpc/src/router/integration/github/github.ts +++ b/packages/trpc/src/router/integration/github/github.ts @@ -11,7 +11,7 @@ import { and, desc, eq, inArray } from "drizzle-orm"; import { z } from "zod"; import { env } from "../../../env"; import { protectedProcedure } from "../../../trpc"; -import { verifyOrgAdmin, verifyOrgMembership } from "./utils"; +import { verifyOrgAdmin, verifyOrgMembership } from "../utils"; const qstash = new Client({ token: env.QSTASH_TOKEN }); diff --git a/packages/trpc/src/router/integration/integration.ts b/packages/trpc/src/router/integration/integration.ts index b07ea3d3496..46f1ca428e0 100644 --- a/packages/trpc/src/router/integration/integration.ts +++ b/packages/trpc/src/router/integration/integration.ts @@ -1,12 +1,13 @@ import { db } from "@superset/db/client"; -import { integrationConnections, members } from "@superset/db/schema"; +import { integrationConnections } from "@superset/db/schema"; import type { TRPCRouterRecord } from "@trpc/server"; -import { and, eq } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import { z } from "zod"; import { protectedProcedure } from "../../trpc"; import { githubRouter } from "./github"; import { linearRouter } from "./linear"; import { slackRouter } from "./slack"; +import { verifyOrgMembership } from "./utils"; export const integrationRouter = { github: githubRouter, @@ -16,15 +17,7 @@ export const integrationRouter = { list: protectedProcedure .input(z.object({ organizationId: z.uuid() })) .query(async ({ ctx, input }) => { - const membership = await db.query.members.findFirst({ - where: and( - eq(members.organizationId, input.organizationId), - eq(members.userId, ctx.session.user.id), - ), - }); - if (!membership) { - throw new Error("Not a member of this organization"); - } + await verifyOrgMembership(ctx.session.user.id, input.organizationId); return db.query.integrationConnections.findMany({ where: eq(integrationConnections.organizationId, input.organizationId), diff --git a/packages/trpc/src/router/integration/linear/linear.ts b/packages/trpc/src/router/integration/linear/linear.ts index 47f43d48f4c..f4e8320795b 100644 --- a/packages/trpc/src/router/integration/linear/linear.ts +++ b/packages/trpc/src/router/integration/linear/linear.ts @@ -4,7 +4,8 @@ import type { TRPCRouterRecord } from "@trpc/server"; import { and, eq } from "drizzle-orm"; import { z } from "zod"; import { protectedProcedure } from "../../../trpc"; -import { getLinearClient, verifyOrgAdmin, verifyOrgMembership } from "./utils"; +import { verifyOrgAdmin, verifyOrgMembership } from "../utils"; +import { getLinearClient } from "./utils"; export const linearRouter = { getConnection: protectedProcedure diff --git a/packages/trpc/src/router/integration/linear/utils.ts b/packages/trpc/src/router/integration/linear/utils.ts index 6afe04071ed..034954c93e0 100644 --- a/packages/trpc/src/router/integration/linear/utils.ts +++ b/packages/trpc/src/router/integration/linear/utils.ts @@ -1,6 +1,6 @@ import { LinearClient } from "@linear/sdk"; import { db } from "@superset/db/client"; -import { integrationConnections, members } from "@superset/db/schema"; +import { integrationConnections } from "@superset/db/schema"; import { and, eq } from "drizzle-orm"; type Priority = "urgent" | "high" | "medium" | "low" | "none"; @@ -51,31 +51,3 @@ export async function getLinearClient( return new LinearClient({ accessToken: connection.accessToken }); } - -export async function verifyOrgMembership( - userId: string, - organizationId: string, -) { - const membership = await db.query.members.findFirst({ - where: and( - eq(members.organizationId, organizationId), - eq(members.userId, userId), - ), - }); - - if (!membership) { - throw new Error("Not a member of this organization"); - } - - return { membership }; -} - -export async function verifyOrgAdmin(userId: string, organizationId: string) { - const { membership } = await verifyOrgMembership(userId, organizationId); - - if (membership.role !== "admin" && membership.role !== "owner") { - throw new Error("Admin access required"); - } - - return { membership }; -} diff --git a/packages/trpc/src/router/integration/slack/index.ts b/packages/trpc/src/router/integration/slack/index.ts index c4ed1e3f7f7..cc59ff4cd82 100644 --- a/packages/trpc/src/router/integration/slack/index.ts +++ b/packages/trpc/src/router/integration/slack/index.ts @@ -1,6 +1,2 @@ export { slackRouter } from "./slack"; -export { - getSlackConnection, - verifyOrgAdmin, - verifyOrgMembership, -} from "./utils"; +export { getSlackConnection } from "./utils"; diff --git a/packages/trpc/src/router/integration/slack/slack.ts b/packages/trpc/src/router/integration/slack/slack.ts index bc622adf751..156f4ec8e30 100644 --- a/packages/trpc/src/router/integration/slack/slack.ts +++ b/packages/trpc/src/router/integration/slack/slack.ts @@ -1,10 +1,10 @@ import { db } from "@superset/db/client"; -import { integrationConnections, type SlackConfig } from "@superset/db/schema"; +import { integrationConnections } from "@superset/db/schema"; import type { TRPCRouterRecord } from "@trpc/server"; import { and, eq } from "drizzle-orm"; import { z } from "zod"; import { protectedProcedure } from "../../../trpc"; -import { verifyOrgAdmin, verifyOrgMembership } from "./utils"; +import { verifyOrgAdmin, verifyOrgMembership } from "../utils"; export const slackRouter = { getConnection: protectedProcedure @@ -20,7 +20,6 @@ export const slackRouter = { columns: { id: true, externalOrgName: true, - config: true, createdAt: true, }, }); @@ -31,7 +30,6 @@ export const slackRouter = { id: connection.id, externalOrgName: connection.externalOrgName, connectedAt: connection.createdAt, - config: connection.config as SlackConfig | null, }; }), @@ -56,48 +54,4 @@ export const slackRouter = { return { success: true }; }), - - updateConfig: protectedProcedure - .input( - z.object({ - organizationId: z.uuid(), - defaultChannelId: z.string().optional(), - }), - ) - .mutation(async ({ ctx, input }) => { - await verifyOrgAdmin(ctx.session.user.id, input.organizationId); - - // Get current config to preserve botUserId - const connection = await db.query.integrationConnections.findFirst({ - where: and( - eq(integrationConnections.organizationId, input.organizationId), - eq(integrationConnections.provider, "slack"), - ), - columns: { config: true }, - }); - - if (!connection) { - return { success: false, error: "No connection found" }; - } - - const currentConfig = connection.config as SlackConfig | null; - - const config: SlackConfig = { - provider: "slack", - botUserId: currentConfig?.botUserId ?? "", - defaultChannelId: input.defaultChannelId, - }; - - await db - .update(integrationConnections) - .set({ config }) - .where( - and( - eq(integrationConnections.organizationId, input.organizationId), - eq(integrationConnections.provider, "slack"), - ), - ); - - return { success: true }; - }), } satisfies TRPCRouterRecord; diff --git a/packages/trpc/src/router/integration/slack/utils.ts b/packages/trpc/src/router/integration/slack/utils.ts index e086ea240ee..bc4e2a3eec3 100644 --- a/packages/trpc/src/router/integration/slack/utils.ts +++ b/packages/trpc/src/router/integration/slack/utils.ts @@ -1,35 +1,7 @@ import { db } from "@superset/db/client"; -import { integrationConnections, members } from "@superset/db/schema"; +import { integrationConnections } from "@superset/db/schema"; import { and, eq } from "drizzle-orm"; -export async function verifyOrgMembership( - userId: string, - organizationId: string, -) { - const membership = await db.query.members.findFirst({ - where: and( - eq(members.organizationId, organizationId), - eq(members.userId, userId), - ), - }); - - if (!membership) { - throw new Error("Not a member of this organization"); - } - - return { membership }; -} - -export async function verifyOrgAdmin(userId: string, organizationId: string) { - const { membership } = await verifyOrgMembership(userId, organizationId); - - if (membership.role !== "admin" && membership.role !== "owner") { - throw new Error("Admin access required"); - } - - return { membership }; -} - export async function getSlackConnection(organizationId: string) { const connection = await db.query.integrationConnections.findFirst({ where: and( diff --git a/packages/trpc/src/router/integration/github/utils.ts b/packages/trpc/src/router/integration/utils.ts similarity index 100% rename from packages/trpc/src/router/integration/github/utils.ts rename to packages/trpc/src/router/integration/utils.ts From ae43f0e1528102e54d0525d451d5083d06477366 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Thu, 29 Jan 2026 22:50:32 -0800 Subject: [PATCH 6/8] refactor(slack): co-locate Slack API code per project conventions Move Slack-specific lib code from src/lib/slack-agent/ into the route handler directory at integrations/slack/events/utils/. Wrap each event processor in its own folder with barrel exports. Move the app manifest into the slack integration directory. Fix implicit-any lint issue in callback/route.ts by restructuring try/catch to use const. --- .../api/integrations/slack/callback/route.ts | 89 +++++++++---------- .../events/process-assistant-message/index.ts | 1 + .../process-assistant-message.ts | 6 +- .../events/process-entity-details/index.ts | 1 + .../process-entity-details.ts | 4 +- .../slack/events/process-link-shared/index.ts | 1 + .../process-link-shared.ts | 4 +- .../slack/events/process-mention/index.ts | 1 + .../{ => process-mention}/process-mention.ts | 6 +- .../slack/events/utils/run-agent/index.ts | 2 + .../events/utils/run-agent}/mcp-clients.ts | 0 .../events/utils/run-agent}/run-agent.ts | 3 +- .../slack/events/utils/slack-blocks/index.ts | 11 +++ .../utils/slack-blocks}/slack-blocks.ts | 0 .../slack/events/utils/slack-client/index.ts | 1 + .../utils/slack-client}/slack-client.ts | 0 .../slack/events/utils/work-objects/index.ts | 5 ++ .../utils/work-objects}/work-objects.ts | 0 .../app/api/integrations/slack/manifest.json} | 0 19 files changed, 78 insertions(+), 57 deletions(-) create mode 100644 apps/api/src/app/api/integrations/slack/events/process-assistant-message/index.ts rename apps/api/src/app/api/integrations/slack/events/{ => process-assistant-message}/process-assistant-message.ts (93%) create mode 100644 apps/api/src/app/api/integrations/slack/events/process-entity-details/index.ts rename apps/api/src/app/api/integrations/slack/events/{ => process-entity-details}/process-entity-details.ts (96%) create mode 100644 apps/api/src/app/api/integrations/slack/events/process-link-shared/index.ts rename apps/api/src/app/api/integrations/slack/events/{ => process-link-shared}/process-link-shared.ts (95%) create mode 100644 apps/api/src/app/api/integrations/slack/events/process-mention/index.ts rename apps/api/src/app/api/integrations/slack/events/{ => process-mention}/process-mention.ts (93%) create mode 100644 apps/api/src/app/api/integrations/slack/events/utils/run-agent/index.ts rename apps/api/src/{lib/slack-agent => app/api/integrations/slack/events/utils/run-agent}/mcp-clients.ts (100%) rename apps/api/src/{lib/slack-agent => app/api/integrations/slack/events/utils/run-agent}/run-agent.ts (99%) create mode 100644 apps/api/src/app/api/integrations/slack/events/utils/slack-blocks/index.ts rename apps/api/src/{lib/slack-agent => app/api/integrations/slack/events/utils/slack-blocks}/slack-blocks.ts (100%) create mode 100644 apps/api/src/app/api/integrations/slack/events/utils/slack-client/index.ts rename apps/api/src/{lib/slack-agent => app/api/integrations/slack/events/utils/slack-client}/slack-client.ts (100%) create mode 100644 apps/api/src/app/api/integrations/slack/events/utils/work-objects/index.ts rename apps/api/src/{lib/slack-agent => app/api/integrations/slack/events/utils/work-objects}/work-objects.ts (100%) rename apps/api/{slack-app-manifest.json => src/app/api/integrations/slack/manifest.json} (100%) diff --git a/apps/api/src/app/api/integrations/slack/callback/route.ts b/apps/api/src/app/api/integrations/slack/callback/route.ts index 323c607561c..0a59e6cb789 100644 --- a/apps/api/src/app/api/integrations/slack/callback/route.ts +++ b/apps/api/src/app/api/integrations/slack/callback/route.ts @@ -57,64 +57,63 @@ export async function GET(request: Request) { const redirectUri = `${env.NEXT_PUBLIC_API_URL}/api/integrations/slack/callback`; const client = new WebClient(); - let tokenData; try { - tokenData = await client.oauth.v2.access({ + const tokenData = await client.oauth.v2.access({ client_id: env.SLACK_CLIENT_ID, client_secret: env.SLACK_CLIENT_SECRET, redirect_uri: redirectUri, code, }); - } catch (error) { - console.error("[slack/callback] Token exchange failed:", error); - return Response.redirect( - `${env.NEXT_PUBLIC_WEB_URL}/integrations/slack?error=token_exchange_failed`, - ); - } - if (!tokenData.ok || !tokenData.access_token || !tokenData.team) { - console.error("[slack/callback] Slack API error:", tokenData.error); - return Response.redirect( - `${env.NEXT_PUBLIC_WEB_URL}/integrations/slack?error=slack_api_error`, - ); - } + if (!tokenData.ok || !tokenData.access_token || !tokenData.team) { + console.error("[slack/callback] Slack API error:", tokenData.error); + return Response.redirect( + `${env.NEXT_PUBLIC_WEB_URL}/integrations/slack?error=slack_api_error`, + ); + } - const config: SlackConfig = { - provider: "slack", - }; - - // Slack bot tokens don't expire, so no tokenExpiresAt - await db - .insert(integrationConnections) - .values({ - organizationId, - connectedByUserId: userId, + const config: SlackConfig = { provider: "slack", - accessToken: tokenData.access_token, - externalOrgId: tokenData.team.id, - externalOrgName: tokenData.team.name, - config, - }) - .onConflictDoUpdate({ - target: [ - integrationConnections.organizationId, - integrationConnections.provider, - ], - set: { + }; + + // Slack bot tokens don't expire, so no tokenExpiresAt + await db + .insert(integrationConnections) + .values({ + organizationId, + connectedByUserId: userId, + provider: "slack", accessToken: tokenData.access_token, externalOrgId: tokenData.team.id, externalOrgName: tokenData.team.name, - connectedByUserId: userId, config, - updatedAt: new Date(), - }, - }); + }) + .onConflictDoUpdate({ + target: [ + integrationConnections.organizationId, + integrationConnections.provider, + ], + set: { + accessToken: tokenData.access_token, + externalOrgId: tokenData.team.id, + externalOrgName: tokenData.team.name, + connectedByUserId: userId, + config, + updatedAt: new Date(), + }, + }); - console.log("[slack/callback] Connected workspace:", { - organizationId, - teamId: tokenData.team.id, - teamName: tokenData.team.name, - }); + console.log("[slack/callback] Connected workspace:", { + organizationId, + teamId: tokenData.team.id, + teamName: tokenData.team.name, + }); - return Response.redirect(`${env.NEXT_PUBLIC_WEB_URL}/integrations/slack`); + return Response.redirect(`${env.NEXT_PUBLIC_WEB_URL}/integrations/slack`); + } catch (error) { + console.error("[slack/callback] Token exchange failed:", error); + return Response.redirect( + `${env.NEXT_PUBLIC_WEB_URL}/integrations/slack?error=token_exchange_failed`, + ); + } } diff --git a/apps/api/src/app/api/integrations/slack/events/process-assistant-message/index.ts b/apps/api/src/app/api/integrations/slack/events/process-assistant-message/index.ts new file mode 100644 index 00000000000..f83d336775b --- /dev/null +++ b/apps/api/src/app/api/integrations/slack/events/process-assistant-message/index.ts @@ -0,0 +1 @@ +export { processAssistantMessage } from "./process-assistant-message"; diff --git a/apps/api/src/app/api/integrations/slack/events/process-assistant-message.ts b/apps/api/src/app/api/integrations/slack/events/process-assistant-message/process-assistant-message.ts similarity index 93% rename from apps/api/src/app/api/integrations/slack/events/process-assistant-message.ts rename to apps/api/src/app/api/integrations/slack/events/process-assistant-message/process-assistant-message.ts index 59ef228ff72..76b59548977 100644 --- a/apps/api/src/app/api/integrations/slack/events/process-assistant-message.ts +++ b/apps/api/src/app/api/integrations/slack/events/process-assistant-message/process-assistant-message.ts @@ -2,9 +2,9 @@ import type { GenericMessageEvent } from "@slack/types"; import { db } from "@superset/db/client"; import { integrationConnections } from "@superset/db/schema"; import { and, eq } from "drizzle-orm"; -import { runSlackAgent } from "@/lib/slack-agent/run-agent"; -import { formatActionsAsText } from "@/lib/slack-agent/slack-blocks"; -import { createSlackClient } from "@/lib/slack-agent/slack-client"; +import { runSlackAgent } from "../utils/run-agent"; +import { formatActionsAsText } from "../utils/slack-blocks"; +import { createSlackClient } from "../utils/slack-client"; interface ProcessAssistantMessageParams { event: GenericMessageEvent; diff --git a/apps/api/src/app/api/integrations/slack/events/process-entity-details/index.ts b/apps/api/src/app/api/integrations/slack/events/process-entity-details/index.ts new file mode 100644 index 00000000000..7f6c2b4c06d --- /dev/null +++ b/apps/api/src/app/api/integrations/slack/events/process-entity-details/index.ts @@ -0,0 +1 @@ +export { processEntityDetails } from "./process-entity-details"; diff --git a/apps/api/src/app/api/integrations/slack/events/process-entity-details.ts b/apps/api/src/app/api/integrations/slack/events/process-entity-details/process-entity-details.ts similarity index 96% rename from apps/api/src/app/api/integrations/slack/events/process-entity-details.ts rename to apps/api/src/app/api/integrations/slack/events/process-entity-details/process-entity-details.ts index 24bcffd7a04..98a7230506c 100644 --- a/apps/api/src/app/api/integrations/slack/events/process-entity-details.ts +++ b/apps/api/src/app/api/integrations/slack/events/process-entity-details/process-entity-details.ts @@ -2,11 +2,11 @@ import type { SlackEvent } from "@slack/types"; import { db } from "@superset/db/client"; import { integrationConnections, tasks } from "@superset/db/schema"; import { and, eq } from "drizzle-orm"; -import { createSlackClient } from "@/lib/slack-agent/slack-client"; +import { createSlackClient } from "../utils/slack-client"; import { createTaskFlexpaneObject, parseTaskSlugFromUrl, -} from "@/lib/slack-agent/work-objects"; +} from "../utils/work-objects"; type EntityDetailsRequestedEvent = Extract< SlackEvent, diff --git a/apps/api/src/app/api/integrations/slack/events/process-link-shared/index.ts b/apps/api/src/app/api/integrations/slack/events/process-link-shared/index.ts new file mode 100644 index 00000000000..de3fb8e7237 --- /dev/null +++ b/apps/api/src/app/api/integrations/slack/events/process-link-shared/index.ts @@ -0,0 +1 @@ +export { processLinkShared } from "./process-link-shared"; diff --git a/apps/api/src/app/api/integrations/slack/events/process-link-shared.ts b/apps/api/src/app/api/integrations/slack/events/process-link-shared/process-link-shared.ts similarity index 95% rename from apps/api/src/app/api/integrations/slack/events/process-link-shared.ts rename to apps/api/src/app/api/integrations/slack/events/process-link-shared/process-link-shared.ts index 035159281b9..d01a53f16d5 100644 --- a/apps/api/src/app/api/integrations/slack/events/process-link-shared.ts +++ b/apps/api/src/app/api/integrations/slack/events/process-link-shared/process-link-shared.ts @@ -2,11 +2,11 @@ import type { EntityMetadata, LinkSharedEvent } from "@slack/types"; import { db } from "@superset/db/client"; import { integrationConnections, tasks } from "@superset/db/schema"; import { and, eq } from "drizzle-orm"; -import { createSlackClient } from "@/lib/slack-agent/slack-client"; +import { createSlackClient } from "../utils/slack-client"; import { createTaskWorkObject, parseTaskSlugFromUrl, -} from "@/lib/slack-agent/work-objects"; +} from "../utils/work-objects"; interface ProcessLinkSharedParams { event: LinkSharedEvent; diff --git a/apps/api/src/app/api/integrations/slack/events/process-mention/index.ts b/apps/api/src/app/api/integrations/slack/events/process-mention/index.ts new file mode 100644 index 00000000000..5a7dbc2681b --- /dev/null +++ b/apps/api/src/app/api/integrations/slack/events/process-mention/index.ts @@ -0,0 +1 @@ +export { processSlackMention } from "./process-mention"; diff --git a/apps/api/src/app/api/integrations/slack/events/process-mention.ts b/apps/api/src/app/api/integrations/slack/events/process-mention/process-mention.ts similarity index 93% rename from apps/api/src/app/api/integrations/slack/events/process-mention.ts rename to apps/api/src/app/api/integrations/slack/events/process-mention/process-mention.ts index 87823b63f67..77c8b46c131 100644 --- a/apps/api/src/app/api/integrations/slack/events/process-mention.ts +++ b/apps/api/src/app/api/integrations/slack/events/process-mention/process-mention.ts @@ -2,9 +2,9 @@ import type { AppMentionEvent } from "@slack/types"; import { db } from "@superset/db/client"; import { integrationConnections } from "@superset/db/schema"; import { and, eq } from "drizzle-orm"; -import { runSlackAgent } from "@/lib/slack-agent/run-agent"; -import { formatActionsAsText } from "@/lib/slack-agent/slack-blocks"; -import { createSlackClient } from "@/lib/slack-agent/slack-client"; +import { runSlackAgent } from "../utils/run-agent"; +import { formatActionsAsText } from "../utils/slack-blocks"; +import { createSlackClient } from "../utils/slack-client"; interface ProcessMentionParams { event: AppMentionEvent; diff --git a/apps/api/src/app/api/integrations/slack/events/utils/run-agent/index.ts b/apps/api/src/app/api/integrations/slack/events/utils/run-agent/index.ts new file mode 100644 index 00000000000..111971d8f96 --- /dev/null +++ b/apps/api/src/app/api/integrations/slack/events/utils/run-agent/index.ts @@ -0,0 +1,2 @@ +export type { SlackAgentResult } from "./run-agent"; +export { runSlackAgent } from "./run-agent"; diff --git a/apps/api/src/lib/slack-agent/mcp-clients.ts b/apps/api/src/app/api/integrations/slack/events/utils/run-agent/mcp-clients.ts similarity index 100% rename from apps/api/src/lib/slack-agent/mcp-clients.ts rename to apps/api/src/app/api/integrations/slack/events/utils/run-agent/mcp-clients.ts diff --git a/apps/api/src/lib/slack-agent/run-agent.ts b/apps/api/src/app/api/integrations/slack/events/utils/run-agent/run-agent.ts similarity index 99% rename from apps/api/src/lib/slack-agent/run-agent.ts rename to apps/api/src/app/api/integrations/slack/events/utils/run-agent/run-agent.ts index ebb854a3fb0..c6b64c8eece 100644 --- a/apps/api/src/lib/slack-agent/run-agent.ts +++ b/apps/api/src/app/api/integrations/slack/events/utils/run-agent/run-agent.ts @@ -5,14 +5,13 @@ import { WebClient } from "@slack/web-api"; import { db } from "@superset/db/client"; import { integrationConnections } from "@superset/db/schema"; import { and, eq } from "drizzle-orm"; - +import type { AgentAction } from "../slack-blocks"; import { createSlackMcpClient, createSupersetMcpClient, mcpToolToAnthropicTool, parseToolName, } from "./mcp-clients"; -import type { AgentAction } from "./slack-blocks"; /** * Fetches thread context (previous messages) for the agent. diff --git a/apps/api/src/app/api/integrations/slack/events/utils/slack-blocks/index.ts b/apps/api/src/app/api/integrations/slack/events/utils/slack-blocks/index.ts new file mode 100644 index 00000000000..3ded15d56e2 --- /dev/null +++ b/apps/api/src/app/api/integrations/slack/events/utils/slack-blocks/index.ts @@ -0,0 +1,11 @@ +export type { + AgentAction, + AgentActionType, + TaskData, + WorkspaceData, +} from "./slack-blocks"; +export { + createAgentResponse, + createAgentResponseBlocks, + formatActionsAsText, +} from "./slack-blocks"; diff --git a/apps/api/src/lib/slack-agent/slack-blocks.ts b/apps/api/src/app/api/integrations/slack/events/utils/slack-blocks/slack-blocks.ts similarity index 100% rename from apps/api/src/lib/slack-agent/slack-blocks.ts rename to apps/api/src/app/api/integrations/slack/events/utils/slack-blocks/slack-blocks.ts diff --git a/apps/api/src/app/api/integrations/slack/events/utils/slack-client/index.ts b/apps/api/src/app/api/integrations/slack/events/utils/slack-client/index.ts new file mode 100644 index 00000000000..b6222c22103 --- /dev/null +++ b/apps/api/src/app/api/integrations/slack/events/utils/slack-client/index.ts @@ -0,0 +1 @@ +export { createSlackClient } from "./slack-client"; diff --git a/apps/api/src/lib/slack-agent/slack-client.ts b/apps/api/src/app/api/integrations/slack/events/utils/slack-client/slack-client.ts similarity index 100% rename from apps/api/src/lib/slack-agent/slack-client.ts rename to apps/api/src/app/api/integrations/slack/events/utils/slack-client/slack-client.ts diff --git a/apps/api/src/app/api/integrations/slack/events/utils/work-objects/index.ts b/apps/api/src/app/api/integrations/slack/events/utils/work-objects/index.ts new file mode 100644 index 00000000000..f7d09d283a0 --- /dev/null +++ b/apps/api/src/app/api/integrations/slack/events/utils/work-objects/index.ts @@ -0,0 +1,5 @@ +export { + createTaskFlexpaneObject, + createTaskWorkObject, + parseTaskSlugFromUrl, +} from "./work-objects"; diff --git a/apps/api/src/lib/slack-agent/work-objects.ts b/apps/api/src/app/api/integrations/slack/events/utils/work-objects/work-objects.ts similarity index 100% rename from apps/api/src/lib/slack-agent/work-objects.ts rename to apps/api/src/app/api/integrations/slack/events/utils/work-objects/work-objects.ts diff --git a/apps/api/slack-app-manifest.json b/apps/api/src/app/api/integrations/slack/manifest.json similarity index 100% rename from apps/api/slack-app-manifest.json rename to apps/api/src/app/api/integrations/slack/manifest.json From af5f313ba68b2a11a6cd4417406fb70bc1a6e84e Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Thu, 29 Jan 2026 23:27:08 -0800 Subject: [PATCH 7/8] refactor(slack): clean up dead code, comments, dev bypasses, and hardcoded URLs Remove unused rich attachment functions from slack-blocks, replace hardcoded WEB_APP_URL with env.NEXT_PUBLIC_WEB_URL, strip restating comments across all Slack files, remove isDev local dev bypasses (use ngrok instead), delete unused AgentActionType export, and register Slack secrets in deploy workflows. --- .github/workflows/deploy-preview.yml | 6 + .github/workflows/deploy-production.yml | 6 + .../api/integrations/slack/callback/route.ts | 5 +- .../api/integrations/slack/connect/route.ts | 45 ++-- .../process-assistant-message.ts | 20 +- .../process-entity-details.ts | 18 +- .../process-link-shared.ts | 21 +- .../events/process-mention/process-mention.ts | 19 +- .../api/integrations/slack/events/route.ts | 142 +++--------- .../events/utils/run-agent/mcp-clients.ts | 16 +- .../slack/events/utils/run-agent/run-agent.ts | 61 +----- .../slack/events/utils/slack-blocks/index.ts | 13 +- .../events/utils/slack-blocks/slack-blocks.ts | 202 +----------------- .../events/utils/work-objects/work-objects.ts | 51 +---- .../slack/jobs/process-mention/route.ts | 29 ++- 15 files changed, 97 insertions(+), 557 deletions(-) diff --git a/.github/workflows/deploy-preview.yml b/.github/workflows/deploy-preview.yml index 840efc5ff7f..b7e2491b8b0 100644 --- a/.github/workflows/deploy-preview.yml +++ b/.github/workflows/deploy-preview.yml @@ -189,6 +189,9 @@ jobs: LINEAR_CLIENT_ID: ${{ secrets.LINEAR_CLIENT_ID }} LINEAR_CLIENT_SECRET: ${{ secrets.LINEAR_CLIENT_SECRET }} LINEAR_WEBHOOK_SECRET: ${{ secrets.LINEAR_WEBHOOK_SECRET }} + SLACK_CLIENT_ID: ${{ secrets.SLACK_CLIENT_ID }} + SLACK_CLIENT_SECRET: ${{ secrets.SLACK_CLIENT_SECRET }} + SLACK_SIGNING_SECRET: ${{ secrets.SLACK_SIGNING_SECRET }} GH_APP_ID: ${{ secrets.GH_APP_ID }} GH_APP_PRIVATE_KEY: ${{ secrets.GH_APP_PRIVATE_KEY }} GH_WEBHOOK_SECRET: ${{ secrets.GH_WEBHOOK_SECRET }} @@ -229,6 +232,9 @@ jobs: --env LINEAR_CLIENT_ID=$LINEAR_CLIENT_ID \ --env LINEAR_CLIENT_SECRET=$LINEAR_CLIENT_SECRET \ --env LINEAR_WEBHOOK_SECRET=$LINEAR_WEBHOOK_SECRET \ + --env SLACK_CLIENT_ID=$SLACK_CLIENT_ID \ + --env SLACK_CLIENT_SECRET=$SLACK_CLIENT_SECRET \ + --env SLACK_SIGNING_SECRET=$SLACK_SIGNING_SECRET \ --env GH_APP_ID="$GH_APP_ID" \ --env GH_APP_PRIVATE_KEY="$GH_APP_PRIVATE_KEY" \ --env GH_WEBHOOK_SECRET="$GH_WEBHOOK_SECRET" \ diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml index ad96f6c91b5..b3b68d22840 100644 --- a/.github/workflows/deploy-production.yml +++ b/.github/workflows/deploy-production.yml @@ -93,6 +93,9 @@ jobs: LINEAR_CLIENT_ID: ${{ secrets.LINEAR_CLIENT_ID }} LINEAR_CLIENT_SECRET: ${{ secrets.LINEAR_CLIENT_SECRET }} LINEAR_WEBHOOK_SECRET: ${{ secrets.LINEAR_WEBHOOK_SECRET }} + SLACK_CLIENT_ID: ${{ secrets.SLACK_CLIENT_ID }} + SLACK_CLIENT_SECRET: ${{ secrets.SLACK_CLIENT_SECRET }} + SLACK_SIGNING_SECRET: ${{ secrets.SLACK_SIGNING_SECRET }} GH_APP_ID: ${{ secrets.GH_APP_ID }} GH_APP_PRIVATE_KEY: ${{ secrets.GH_APP_PRIVATE_KEY }} GH_WEBHOOK_SECRET: ${{ secrets.GH_WEBHOOK_SECRET }} @@ -133,6 +136,9 @@ jobs: --env LINEAR_CLIENT_ID=$LINEAR_CLIENT_ID \ --env LINEAR_CLIENT_SECRET=$LINEAR_CLIENT_SECRET \ --env LINEAR_WEBHOOK_SECRET=$LINEAR_WEBHOOK_SECRET \ + --env SLACK_CLIENT_ID=$SLACK_CLIENT_ID \ + --env SLACK_CLIENT_SECRET=$SLACK_CLIENT_SECRET \ + --env SLACK_SIGNING_SECRET=$SLACK_SIGNING_SECRET \ --env GH_APP_ID="$GH_APP_ID" \ --env GH_APP_PRIVATE_KEY="$GH_APP_PRIVATE_KEY" \ --env GH_WEBHOOK_SECRET="$GH_WEBHOOK_SECRET" \ diff --git a/apps/api/src/app/api/integrations/slack/callback/route.ts b/apps/api/src/app/api/integrations/slack/callback/route.ts index 0a59e6cb789..7b8781d8064 100644 --- a/apps/api/src/app/api/integrations/slack/callback/route.ts +++ b/apps/api/src/app/api/integrations/slack/callback/route.ts @@ -25,7 +25,6 @@ export async function GET(request: Request) { ); } - // Verify signed state (prevents forgery) const stateData = verifySignedState(state); if (!stateData) { return Response.redirect( @@ -35,7 +34,7 @@ export async function GET(request: Request) { const { organizationId, userId } = stateData; - // Re-verify membership at callback time (defense-in-depth) + // Re-verify membership at callback time (state was signed earlier) const membership = await db.query.members.findFirst({ where: and( eq(members.organizationId, organizationId), @@ -53,7 +52,6 @@ export async function GET(request: Request) { ); } - // Exchange code for token (redirect_uri must match connect route) const redirectUri = `${env.NEXT_PUBLIC_API_URL}/api/integrations/slack/callback`; const client = new WebClient(); @@ -76,7 +74,6 @@ export async function GET(request: Request) { provider: "slack", }; - // Slack bot tokens don't expire, so no tokenExpiresAt await db .insert(integrationConnections) .values({ diff --git a/apps/api/src/app/api/integrations/slack/connect/route.ts b/apps/api/src/app/api/integrations/slack/connect/route.ts index b27d0a97846..badee4495b6 100644 --- a/apps/api/src/app/api/integrations/slack/connect/route.ts +++ b/apps/api/src/app/api/integrations/slack/connect/route.ts @@ -25,8 +25,6 @@ const SLACK_SCOPES = [ export async function GET(request: Request) { const url = new URL(request.url); const organizationId = url.searchParams.get("organizationId"); - const isDev = env.NODE_ENV === "development"; - if (!organizationId) { return Response.json( { error: "Missing organizationId parameter" }, @@ -34,35 +32,28 @@ export async function GET(request: Request) { ); } - let userId: string; + const session = await auth.api.getSession({ + headers: request.headers, + }); - // In dev, allow passing userId directly (for ngrok testing where cookies don't work) - const devUserId = url.searchParams.get("userId"); - if (isDev && devUserId) { - userId = devUserId; - } else { - const session = await auth.api.getSession({ - headers: request.headers, - }); + if (!session?.user) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } - if (!session?.user) { - return Response.json({ error: "Unauthorized" }, { status: 401 }); - } - userId = session.user.id; + const userId = session.user.id; - const membership = await db.query.members.findFirst({ - where: and( - eq(members.organizationId, organizationId), - eq(members.userId, userId), - ), - }); + const membership = await db.query.members.findFirst({ + where: and( + eq(members.organizationId, organizationId), + eq(members.userId, userId), + ), + }); - if (!membership) { - return Response.json( - { error: "User is not a member of this organization" }, - { status: 403 }, - ); - } + if (!membership) { + return Response.json( + { error: "User is not a member of this organization" }, + { status: 403 }, + ); } const state = createSignedState({ diff --git a/apps/api/src/app/api/integrations/slack/events/process-assistant-message/process-assistant-message.ts b/apps/api/src/app/api/integrations/slack/events/process-assistant-message/process-assistant-message.ts index 76b59548977..b0536dfcb89 100644 --- a/apps/api/src/app/api/integrations/slack/events/process-assistant-message/process-assistant-message.ts +++ b/apps/api/src/app/api/integrations/slack/events/process-assistant-message/process-assistant-message.ts @@ -24,7 +24,6 @@ export async function processAssistantMessage({ user: event.user, }); - // Find connection by Slack team ID const connection = await db.query.integrationConnections.findFirst({ where: and( eq(integrationConnections.provider, "slack"), @@ -42,10 +41,8 @@ export async function processAssistantMessage({ const slack = createSlackClient(connection.accessToken); - // Use thread_ts if in a thread, otherwise use message ts const threadTs = event.thread_ts ?? event.ts; - // Set "thinking" status using assistant API try { await slack.assistant.threads.setStatus({ channel_id: event.channel, @@ -60,7 +57,6 @@ export async function processAssistantMessage({ } try { - // Run the AI agent const result = await runSlackAgent({ prompt: event.text ?? "", channelId: event.channel, @@ -70,8 +66,7 @@ export async function processAssistantMessage({ slackTeamId: teamId, }); - // If we have actions, format them as text with URLs (enables unfurling) - // If no actions, use agent's text response + // Format actions as text with URLs (enables Slack unfurling) const hasActions = result.actions.length > 0; const responseText = hasActions ? formatActionsAsText(result.actions) @@ -82,14 +77,6 @@ export async function processAssistantMessage({ thread_ts: threadTs, text: responseText, }); - - console.log( - "[slack/process-assistant-message] Response posted successfully", - { - hasActions, - actionCount: result.actions.length, - }, - ); } catch (err) { console.error("[slack/process-assistant-message] Agent error:", err); @@ -99,15 +86,12 @@ export async function processAssistantMessage({ text: `Sorry, something went wrong: ${err instanceof Error ? err.message : "Unknown error"}`, }); } finally { - // Clear the status try { await slack.assistant.threads.setStatus({ channel_id: event.channel, thread_ts: threadTs, status: "", }); - } catch { - // Ignore errors clearing status - } + } catch {} } } diff --git a/apps/api/src/app/api/integrations/slack/events/process-entity-details/process-entity-details.ts b/apps/api/src/app/api/integrations/slack/events/process-entity-details/process-entity-details.ts index 98a7230506c..895434309e3 100644 --- a/apps/api/src/app/api/integrations/slack/events/process-entity-details/process-entity-details.ts +++ b/apps/api/src/app/api/integrations/slack/events/process-entity-details/process-entity-details.ts @@ -19,13 +19,7 @@ interface ProcessEntityDetailsParams { eventId: string; } -/** - * Handles the entity_details_requested event. - * - * This event fires when a user clicks on an unfurled Work Object to open - * the flexpane (side panel). We respond with entity.presentDetails to - * populate the flexpane with task details. - */ +/** Populates the flexpane when a user clicks an unfurled Work Object. */ export async function processEntityDetails({ event, teamId, @@ -38,7 +32,6 @@ export async function processEntityDetails({ externalRef: event.external_ref, }); - // Find connection by Slack team ID const connection = await db.query.integrationConnections.findFirst({ where: and( eq(integrationConnections.provider, "slack"), @@ -56,7 +49,6 @@ export async function processEntityDetails({ const slack = createSlackClient(connection.accessToken); - // Parse the task slug from the URL const taskSlug = parseTaskSlugFromUrl(event.entity_url); if (!taskSlug) { @@ -65,7 +57,6 @@ export async function processEntityDetails({ event.entity_url, ); - // Respond with an error try { await slack.entity.presentDetails({ trigger_id: event.trigger_id, @@ -83,7 +74,6 @@ export async function processEntityDetails({ return; } - // Fetch the task from the database with full relations for flexpane const task = await db.query.tasks.findFirst({ where: and( eq(tasks.organizationId, connection.organizationId), @@ -117,7 +107,6 @@ export async function processEntityDetails({ return; } - // Create the Work Object metadata for the flexpane const entity = createTaskFlexpaneObject(task); try { @@ -125,11 +114,6 @@ export async function processEntityDetails({ trigger_id: event.trigger_id, metadata: entity, }); - - console.log( - "[slack/process-entity-details] Flexpane populated successfully for task:", - task.slug, - ); } catch (err) { console.error( "[slack/process-entity-details] Failed to present details:", diff --git a/apps/api/src/app/api/integrations/slack/events/process-link-shared/process-link-shared.ts b/apps/api/src/app/api/integrations/slack/events/process-link-shared/process-link-shared.ts index d01a53f16d5..d63e54d6148 100644 --- a/apps/api/src/app/api/integrations/slack/events/process-link-shared/process-link-shared.ts +++ b/apps/api/src/app/api/integrations/slack/events/process-link-shared/process-link-shared.ts @@ -25,7 +25,6 @@ export async function processLinkShared({ linkCount: event.links.length, }); - // Find connection by Slack team ID const connection = await db.query.integrationConnections.findFirst({ where: and( eq(integrationConnections.provider, "slack"), @@ -43,16 +42,11 @@ export async function processLinkShared({ const slack = createSlackClient(connection.accessToken); - // Build Work Object entities for each link const entities: EntityMetadata[] = []; for (const link of event.links) { const taskSlug = parseTaskSlugFromUrl(link.url); if (!taskSlug) { - console.log( - "[slack/process-link-shared] Could not parse task slug from URL:", - link.url, - ); continue; } @@ -70,33 +64,22 @@ export async function processLinkShared({ if (task) { const entity = createTaskWorkObject(task); - // Ensure app_unfurl_url matches the exact URL from the message + // Must match the exact URL from the message for Slack to unfurl entity.app_unfurl_url = link.url; entities.push(entity); - console.log( - "[slack/process-link-shared] Built Work Object for task:", - task.slug, - ); - } else { - console.log("[slack/process-link-shared] Task not found:", taskSlug); } } - // Send unfurls to Slack using Work Objects metadata format if (entities.length > 0) { try { + // Work Objects use `metadata` instead of the legacy `unfurls` field await slack.chat.unfurl({ channel: event.channel, ts: event.message_ts, - // Work Objects use metadata instead of unfurls metadata: { entities, }, }); - - console.log( - "[slack/process-link-shared] Work Objects unfurls sent successfully", - ); } catch (err) { console.error("[slack/process-link-shared] Failed to send unfurls:", err); } diff --git a/apps/api/src/app/api/integrations/slack/events/process-mention/process-mention.ts b/apps/api/src/app/api/integrations/slack/events/process-mention/process-mention.ts index 77c8b46c131..4d675d9ce65 100644 --- a/apps/api/src/app/api/integrations/slack/events/process-mention/process-mention.ts +++ b/apps/api/src/app/api/integrations/slack/events/process-mention/process-mention.ts @@ -24,7 +24,6 @@ export async function processSlackMention({ user: event.user, }); - // Find connection by Slack team ID const connection = await db.query.integrationConnections.findFirst({ where: and( eq(integrationConnections.provider, "slack"), @@ -42,7 +41,6 @@ export async function processSlackMention({ const slack = createSlackClient(connection.accessToken); - // React with eyes to show we're processing try { await slack.reactions.add({ channel: event.channel, @@ -53,11 +51,9 @@ export async function processSlackMention({ console.warn("[slack/process-mention] Failed to add reaction:", err); } - // Determine the thread timestamp (reply in thread if it's a threaded message) const threadTs = event.thread_ts ?? event.ts; try { - // Run the AI agent const result = await runSlackAgent({ prompt: event.text, channelId: event.channel, @@ -67,8 +63,7 @@ export async function processSlackMention({ slackTeamId: teamId, }); - // If we have actions, format them as text with URLs (enables unfurling) - // If no actions, use agent's text response + // Format actions as text with URLs (enables Slack unfurling) const hasActions = result.actions.length > 0; const responseText = hasActions ? formatActionsAsText(result.actions) @@ -79,31 +74,21 @@ export async function processSlackMention({ thread_ts: threadTs, text: responseText, }); - - console.log("[slack/process-mention] Response posted successfully", { - hasActions, - actionCount: result.actions.length, - actionTypes: result.actions.map((a) => a.type), - }); } catch (err) { console.error("[slack/process-mention] Agent error:", err); - // Post error message to the channel await slack.chat.postMessage({ channel: event.channel, thread_ts: threadTs, text: `Sorry, something went wrong: ${err instanceof Error ? err.message : "Unknown error"}`, }); } finally { - // Remove the eyes reaction try { await slack.reactions.remove({ channel: event.channel, timestamp: event.ts, name: "eyes", }); - } catch { - // Ignore errors removing reaction - } + } catch {} } } diff --git a/apps/api/src/app/api/integrations/slack/events/route.ts b/apps/api/src/app/api/integrations/slack/events/route.ts index 69d108ca602..09314d45740 100644 --- a/apps/api/src/app/api/integrations/slack/events/route.ts +++ b/apps/api/src/app/api/integrations/slack/events/route.ts @@ -2,13 +2,10 @@ import { createHmac, timingSafeEqual } from "node:crypto"; import { Client } from "@upstash/qstash"; import { env } from "@/env"; -import { processAssistantMessage } from "./process-assistant-message"; import { processEntityDetails } from "./process-entity-details"; import { processLinkShared } from "./process-link-shared"; -import { processSlackMention } from "./process-mention"; const qstash = new Client({ token: env.QSTASH_TOKEN }); -const isDev = env.NODE_ENV === "development"; function verifySlackSignature({ body, @@ -19,7 +16,7 @@ function verifySlackSignature({ signature: string; timestamp: string; }): boolean { - // Check timestamp to prevent replay attacks (5 minute window) + // Reject timestamps >5 min old to prevent replay attacks const timestampSec = Number.parseInt(timestamp, 10); const now = Math.floor(Date.now() / 1000); if (Math.abs(now - timestampSec) > 60 * 5) { @@ -27,7 +24,6 @@ function verifySlackSignature({ return false; } - // Create signature base string and verify const sigBase = `v0:${timestamp}:${body}`; const mySignature = `v0=${createHmac("sha256", env.SLACK_SIGNING_SECRET).update(sigBase).digest("hex")}`; @@ -53,7 +49,6 @@ export async function POST(request: Request) { ); } - // Verify signature if (!verifySlackSignature({ body, signature, timestamp })) { console.error("[slack/events] Signature verification failed"); return Response.json({ error: "Invalid signature" }, { status: 401 }); @@ -61,127 +56,55 @@ export async function POST(request: Request) { const payload = JSON.parse(body); - // Handle URL verification challenge (Slack sends this when setting up Events URL) + // Slack sends this once when configuring the Events URL if (payload.type === "url_verification") { return Response.json({ challenge: payload.challenge }); } - // Handle event callbacks if (payload.type === "event_callback") { const { event, team_id, event_id } = payload; - // Handle app_mention events (channel @mentions) if (event.type === "app_mention") { - console.log("[slack/events] Received app_mention:", { - eventId: event_id, - teamId: team_id, - channel: event.channel, - user: event.user, - }); - - // Process async (Slack requires response within 3s) - // In dev, call directly since QStash can't reach localhost - // In prod, queue via QStash for reliability - if (isDev) { - // Fire and forget - don't await - processSlackMention({ - event, - teamId: team_id, - eventId: event_id, - }).catch((error) => { - console.error("[slack/events] Process mention error:", error); + try { + await qstash.publishJSON({ + url: `${env.NEXT_PUBLIC_API_URL}/api/integrations/slack/jobs/process-mention`, + body: { + event, + teamId: team_id, + eventId: event_id, + }, + retries: 3, }); - } else { - try { - await qstash.publishJSON({ - url: `${env.NEXT_PUBLIC_API_URL}/api/integrations/slack/jobs/process-mention`, - body: { - event, - teamId: team_id, - eventId: event_id, - }, - retries: 3, - }); - } catch (error) { - console.error("[slack/events] Failed to queue mention job:", error); - } + } catch (error) { + console.error("[slack/events] Failed to queue mention job:", error); } } - // Handle message.im events (DMs to the bot, including agent messages) if (event.type === "message" && event.channel_type === "im") { // Skip bot messages to prevent infinite loops if (event.bot_id || event.subtype === "bot_message" || !event.user) { - console.log("[slack/events] Skipping bot message"); return new Response("ok", { status: 200 }); } - console.log("[slack/events] Received message.im:", { - eventId: event_id, - teamId: team_id, - channel: event.channel, - user: event.user, - }); - - if (isDev) { - processAssistantMessage({ - event, - teamId: team_id, - eventId: event_id, - }).catch((err: unknown) => { - console.error("[slack/events] Process assistant message error:", err); + try { + await qstash.publishJSON({ + url: `${env.NEXT_PUBLIC_API_URL}/api/integrations/slack/jobs/process-assistant-message`, + body: { + event, + teamId: team_id, + eventId: event_id, + }, + retries: 3, }); - } else { - try { - await qstash.publishJSON({ - url: `${env.NEXT_PUBLIC_API_URL}/api/integrations/slack/jobs/process-assistant-message`, - body: { - event, - teamId: team_id, - eventId: event_id, - }, - retries: 3, - }); - } catch (error) { - console.error( - "[slack/events] Failed to queue assistant message job:", - error, - ); - } + } catch (error) { + console.error( + "[slack/events] Failed to queue assistant message job:", + error, + ); } } - // Handle assistant_thread_started (user opens agent chat) - if (event.type === "assistant_thread_started") { - console.log("[slack/events] Received assistant_thread_started:", { - eventId: event_id, - teamId: team_id, - userId: event.assistant_thread.user_id, - channelId: event.assistant_thread.channel_id, - }); - // Optional: Set initial status or prompts here - } - - // Handle assistant_thread_context_changed (user switches channels) - if (event.type === "assistant_thread_context_changed") { - console.log("[slack/events] Received assistant_thread_context_changed:", { - eventId: event_id, - teamId: team_id, - contextChannelId: event.assistant_thread.context.channel_id, - }); - // Optional: Update context-aware suggestions - } - - // Handle link_shared events (URL unfurling) if (event.type === "link_shared") { - console.log("[slack/events] Received link_shared:", { - eventId: event_id, - teamId: team_id, - channel: event.channel, - links: event.links, - }); - - // Process synchronously since unfurling needs quick response processLinkShared({ event, teamId: team_id, @@ -191,16 +114,7 @@ export async function POST(request: Request) { }); } - // Handle entity_details_requested events (Work Object flexpane) if (event.type === "entity_details_requested") { - console.log("[slack/events] Received entity_details_requested:", { - eventId: event_id, - teamId: team_id, - entityUrl: event.entity_url, - externalRef: event.external_ref, - }); - - // Process synchronously since flexpane needs quick response processEntityDetails({ event, teamId: team_id, @@ -211,6 +125,6 @@ export async function POST(request: Request) { } } - // Always return 200 OK to Slack quickly + // Slack requires 200 within 3s regardless of event type return new Response("ok", { status: 200 }); } diff --git a/apps/api/src/app/api/integrations/slack/events/utils/run-agent/mcp-clients.ts b/apps/api/src/app/api/integrations/slack/events/utils/run-agent/mcp-clients.ts index 769d362284f..69e88f53d4e 100644 --- a/apps/api/src/app/api/integrations/slack/events/utils/run-agent/mcp-clients.ts +++ b/apps/api/src/app/api/integrations/slack/events/utils/run-agent/mcp-clients.ts @@ -9,10 +9,7 @@ interface McpTool { inputSchema: unknown; } -/** - * Creates an MCP client connected to the Superset MCP server in-process. - * Uses InMemoryTransport — no HTTP, no forgeable headers. - */ +// Uses InMemoryTransport — no HTTP, no forgeable headers. export async function createSupersetMcpClient({ organizationId, userId, @@ -23,10 +20,6 @@ export async function createSupersetMcpClient({ return createInMemoryMcpClient({ organizationId, userId }); } -/** - * Creates an MCP client for Slack by spawning the official Slack MCP server. - * Uses the @modelcontextprotocol/server-slack package via npx. - */ export async function createSlackMcpClient({ token, teamId, @@ -53,10 +46,6 @@ export async function createSlackMcpClient({ return client; } -/** - * Converts an MCP tool definition to the Anthropic API tool format. - * Prefixes tool names with the source (superset_ or slack_) for disambiguation. - */ export function mcpToolToAnthropicTool( tool: McpTool, prefix: string, @@ -68,9 +57,6 @@ export function mcpToolToAnthropicTool( }; } -/** - * Parses a prefixed tool name back to the original name and source. - */ export function parseToolName(prefixedName: string): { prefix: string; toolName: string; diff --git a/apps/api/src/app/api/integrations/slack/events/utils/run-agent/run-agent.ts b/apps/api/src/app/api/integrations/slack/events/utils/run-agent/run-agent.ts index c6b64c8eece..958dee399d6 100644 --- a/apps/api/src/app/api/integrations/slack/events/utils/run-agent/run-agent.ts +++ b/apps/api/src/app/api/integrations/slack/events/utils/run-agent/run-agent.ts @@ -13,10 +13,6 @@ import { parseToolName, } from "./mcp-clients"; -/** - * Fetches thread context (previous messages) for the agent. - * Returns formatted string of thread messages. - */ async function fetchThreadContext({ token, channelId, @@ -40,7 +36,7 @@ async function fetchThreadContext({ return ""; } - // Format messages, excluding the current mention (last message) + // Exclude the current mention (last message) const messages = result.messages.slice(0, -1); if (messages.length === 0) { return ""; @@ -71,20 +67,14 @@ export interface SlackAgentResult { actions: AgentAction[]; } -/** - * Extracts action data from MCP tool result for rich Slack formatting. - * Uses structuredContent if available (type-safe), falls back to parsing text content. - */ function getActionFromToolResult( toolName: string, // biome-ignore lint/suspicious/noExplicitAny: MCP result varies by tool result: any, ): AgentAction | null { - // Prefer structuredContent (typed) over parsing text const data = result.structuredContent ?? parseTextContent(result.content); if (!data) return null; - // Map tool results to actions if (toolName === "create_task" && data.created) { return { type: "task_created", @@ -112,7 +102,6 @@ function getActionFromToolResult( }; } - // Workspace actions - desktop returns { workspaceId, workspaceName, branch } if (toolName === "create_workspace" && data.workspaceId) { return { type: "workspace_created", @@ -145,9 +134,6 @@ function getActionFromToolResult( return null; } -/** - * Fallback: parse JSON from text content (for tools without structuredContent) - */ // biome-ignore lint/suspicious/noExplicitAny: MCP content is loosely typed function parseTextContent(content: any): Record | null { try { @@ -165,12 +151,11 @@ function parseTextContent(content: any): Record | null { } } -// Denylist of Superset MCP tools to exclude from Slack agent -// These are desktop-only navigation tools that don't make sense in Slack context +// Desktop-only tools that don't make sense in Slack context const DENIED_SUPERSET_TOOLS = new Set([ - "navigate_to_workspace", // Desktop navigation only - "switch_workspace", // Desktop navigation only - "get_app_context", // Desktop app state + "navigate_to_workspace", + "switch_workspace", + "get_app_context", ]); const SYSTEM_PROMPT = `You are a helpful assistant in Slack for Superset, a task management application. @@ -204,7 +189,6 @@ export async function runSlackAgent( const anthropic = new Anthropic(); const actions: AgentAction[] = []; - // Get the connectedByUserId to use for internal auth const connection = await db.query.integrationConnections.findFirst({ where: and( eq(integrationConnections.organizationId, params.organizationId), @@ -222,11 +206,6 @@ export async function runSlackAgent( let slackMcp: Client | null = null; try { - // Fetch thread context and create MCP clients in parallel - console.log( - "[slack-agent] Fetching thread context and creating MCP clients...", - ); - const [threadContext, supersetMcpResult, slackMcpResult] = await Promise.all([ fetchThreadContext({ @@ -248,17 +227,11 @@ export async function runSlackAgent( cleanupSuperset = supersetMcpResult.cleanup; slackMcp = slackMcpResult; - if (threadContext) { - console.log("[slack-agent] Thread context fetched"); - } - - // List available tools from both MCPs const [supersetToolsResult, slackToolsResult] = await Promise.all([ supersetMcp.listTools(), slackMcp.listTools(), ]); - // Convert MCP tools to Anthropic tool format with prefixes const supersetTools = supersetToolsResult.tools .map((t) => mcpToolToAnthropicTool(t, "superset")) .filter((t) => !DENIED_SUPERSET_TOOLS.has(t.name)); @@ -269,12 +242,6 @@ export async function runSlackAgent( const tools: Anthropic.Tool[] = [...supersetTools, ...slackTools]; - console.log( - "[slack-agent] Available tools:", - tools.map((t) => t.name), - ); - - // Build context-aware system prompt const contextualSystem = `${SYSTEM_PROMPT} Current context: @@ -282,7 +249,6 @@ Current context: - Thread: ${params.threadTs} - Organization ID: ${params.organizationId}`; - // Initialize conversation with thread context if available const userContent = threadContext ? `${threadContext}\n\nCurrent message:\n${params.prompt}` : params.prompt; @@ -294,7 +260,6 @@ Current context: }, ]; - // Agent loop let response = await anthropic.messages.create({ model: "claude-sonnet-4-5", max_tokens: 2048, @@ -303,7 +268,6 @@ Current context: messages, }); - // Process tool calls in a loop until we get a final response const MAX_TOOL_ITERATIONS = 10; let iterations = 0; @@ -319,8 +283,6 @@ Current context: const toolResults: Anthropic.ToolResultBlockParam[] = []; for (const toolUse of toolUseBlocks) { - console.log("[slack-agent] Executing tool:", toolUse.name); - const { prefix, toolName } = parseToolName(toolUse.name); const mcp = prefix === "superset" ? supersetMcp : slackMcp; @@ -344,7 +306,6 @@ Current context: const resultContent = JSON.stringify(result.content); - // Track Superset actions for rich formatting if (prefix === "superset") { const action = getActionFromToolResult(toolName, result); if (action) { @@ -377,11 +338,9 @@ Current context: } } - // Add assistant response and tool results to conversation messages.push({ role: "assistant", content: response.content }); messages.push({ role: "user", content: toolResults }); - // Continue the conversation response = await anthropic.messages.create({ model: "claude-sonnet-4-5", max_tokens: 2048, @@ -391,7 +350,6 @@ Current context: }); } - // Extract text response const textBlock = response.content.find( (b): b is Anthropic.TextBlock => b.type === "text", ); @@ -401,20 +359,15 @@ Current context: actions, }; } finally { - // Cleanup: close MCP clients if (cleanupSuperset) { try { await cleanupSuperset(); - } catch { - // Ignore close errors - } + } catch {} } if (slackMcp) { try { await slackMcp.close(); - } catch { - // Ignore close errors - } + } catch {} } } } diff --git a/apps/api/src/app/api/integrations/slack/events/utils/slack-blocks/index.ts b/apps/api/src/app/api/integrations/slack/events/utils/slack-blocks/index.ts index 3ded15d56e2..b6476f41d92 100644 --- a/apps/api/src/app/api/integrations/slack/events/utils/slack-blocks/index.ts +++ b/apps/api/src/app/api/integrations/slack/events/utils/slack-blocks/index.ts @@ -1,11 +1,2 @@ -export type { - AgentAction, - AgentActionType, - TaskData, - WorkspaceData, -} from "./slack-blocks"; -export { - createAgentResponse, - createAgentResponseBlocks, - formatActionsAsText, -} from "./slack-blocks"; +export type { AgentAction } from "./slack-blocks"; +export { formatActionsAsText } from "./slack-blocks"; diff --git a/apps/api/src/app/api/integrations/slack/events/utils/slack-blocks/slack-blocks.ts b/apps/api/src/app/api/integrations/slack/events/utils/slack-blocks/slack-blocks.ts index 303a6aa732a..5af0931c8d9 100644 --- a/apps/api/src/app/api/integrations/slack/events/utils/slack-blocks/slack-blocks.ts +++ b/apps/api/src/app/api/integrations/slack/events/utils/slack-blocks/slack-blocks.ts @@ -1,15 +1,5 @@ -import type { KnownBlock, MessageAttachment } from "@slack/web-api"; - import { env } from "@/env"; -// Action types the agent can perform -export type AgentActionType = - | "task_created" - | "task_updated" - | "task_deleted" - | "workspace_created" - | "workspace_switched"; - export interface TaskData { id: string; slug: string; @@ -35,31 +25,18 @@ export type AgentAction = workspaces: WorkspaceData[]; }; -type TaskActionType = "task_created" | "task_updated" | "task_deleted"; - -// Superset logo for attachment cards -const SUPERSET_ICON_URL = "https://superset.sh/favicon-192.png"; - -/** - * Formats actions into simple text with URLs (for unfurling). - * Used when we have actions - we skip the agent's text and use this instead. - * URLs use web app domain to match unfurl_domains and trigger Slack unfurling. - */ -// Production web app URL for unfurl links (localhost won't unfurl) -const WEB_APP_URL = "https://app.superset.sh"; - export function formatActionsAsText(actions: AgentAction[]): string { const lines: string[] = []; for (const action of actions) { if (action.type === "task_created") { for (const task of action.tasks) { - const url = `${WEB_APP_URL}/tasks/${task.slug}`; + const url = `${env.NEXT_PUBLIC_WEB_URL}/tasks/${task.slug}`; lines.push(`Created task <${url}|${task.slug}>`); } } else if (action.type === "task_updated") { for (const task of action.tasks) { - const url = `${WEB_APP_URL}/tasks/${task.slug}`; + const url = `${env.NEXT_PUBLIC_WEB_URL}/tasks/${task.slug}`; lines.push(`Updated task <${url}|${task.slug}>`); } } else if (action.type === "task_deleted") { @@ -81,178 +58,3 @@ export function formatActionsAsText(actions: AgentAction[]): string { return lines.join("\n"); } - -/** - * Creates a rich attachment card for a single task (Linear-style). - * Matches Linear's clean design: icon, title, subtitle, description, status. - */ -function createTaskAttachment( - task: TaskData, - _actionType: TaskActionType, -): MessageAttachment { - const taskUrl = `${env.NEXT_PUBLIC_WEB_URL}/tasks/${task.slug}`; - - const fields: { title: string; value: string; short: boolean }[] = []; - - // Add status field - if (task.status) { - fields.push({ - title: "Status", - value: task.status, - short: true, - }); - } - - // Add priority field if set and not "none" - if (task.priority && task.priority !== "none") { - fields.push({ - title: "Priority", - value: formatPriority(task.priority), - short: true, - }); - } - - // Build description with task ID subtitle - const subtitle = `Task ${task.slug} in Superset`; - const text = task.description - ? `${subtitle}\n\n${task.description}` - : subtitle; - - return { - color: "#7C3AED", // Superset purple - author_icon: SUPERSET_ICON_URL, - author_name: task.title, - author_link: taskUrl, - text, - fields: fields.length > 0 ? fields : undefined, - ts: String(Math.floor(Date.now() / 1000)), - }; -} - -/** - * Creates a rich attachment card for a workspace (Linear-style). - */ -function createWorkspaceAttachment( - workspace: WorkspaceData, - _actionType: "workspace_created" | "workspace_switched", -): MessageAttachment { - const deepLink = `superset://workspace/${workspace.id}`; - - const fields: { title: string; value: string; short: boolean }[] = []; - - if (workspace.branch) { - fields.push({ - title: "Branch", - value: `\`${workspace.branch}\``, - short: true, - }); - } - - const subtitle = `Workspace in Superset`; - - return { - color: "#7C3AED", // Superset purple - author_icon: SUPERSET_ICON_URL, - author_name: workspace.name, - author_link: deepLink, - text: subtitle, - fields: fields.length > 0 ? fields : undefined, - ts: String(Math.floor(Date.now() / 1000)), - }; -} - -/** - * Creates attachments for an agent action. - */ -function createActionAttachments(action: AgentAction): MessageAttachment[] { - const attachments: MessageAttachment[] = []; - - // Handle task actions - if ( - action.type === "task_created" || - action.type === "task_updated" || - action.type === "task_deleted" - ) { - for (const task of action.tasks) { - attachments.push(createTaskAttachment(task, action.type)); - } - } - - // Handle workspace actions - if ( - action.type === "workspace_created" || - action.type === "workspace_switched" - ) { - for (const workspace of action.workspaces) { - attachments.push(createWorkspaceAttachment(workspace, action.type)); - } - } - - return attachments; -} - -/** - * Creates the full message response for a Slack agent. - * Returns text, blocks for the main message, and attachments for rich cards. - */ -export function createAgentResponse({ - text, - actions, -}: { - text: string; - actions: AgentAction[]; -}): { - text: string; - blocks: KnownBlock[]; - attachments: MessageAttachment[]; -} { - const blocks: KnownBlock[] = []; - const attachments: MessageAttachment[] = []; - - // Add text as a block - if (text) { - blocks.push({ - type: "section", - text: { - type: "mrkdwn", - text, - }, - }); - } - - // Add attachments for each action - for (const action of actions) { - const hasItems = - ("tasks" in action && action.tasks.length > 0) || - ("workspaces" in action && action.workspaces.length > 0); - - if (hasItems) { - attachments.push(...createActionAttachments(action)); - } - } - - return { text, blocks, attachments }; -} - -// Keep the old function for backwards compatibility but mark as deprecated -/** @deprecated Use createAgentResponse instead */ -export function createAgentResponseBlocks({ - text, - actions, -}: { - text: string; - actions: AgentAction[]; -}): KnownBlock[] { - return createAgentResponse({ text, actions }).blocks; -} - -function formatPriority(priority: string): string { - const labels: Record = { - urgent: "🔴 Urgent", - high: "🟠 High", - medium: "🟡 Medium", - low: "🟢 Low", - none: "None", - }; - return labels[priority] ?? priority; -} diff --git a/apps/api/src/app/api/integrations/slack/events/utils/work-objects/work-objects.ts b/apps/api/src/app/api/integrations/slack/events/utils/work-objects/work-objects.ts index 230f4f2fc84..44dcf6f5805 100644 --- a/apps/api/src/app/api/integrations/slack/events/utils/work-objects/work-objects.ts +++ b/apps/api/src/app/api/integrations/slack/events/utils/work-objects/work-objects.ts @@ -1,11 +1,4 @@ -/** - * Slack Work Objects utilities for tasks. - * - * Work Objects provide rich unfurl cards with flexpane support, - * allowing users to view and interact with Superset data directly in Slack. - * - * @see https://docs.slack.dev/messaging/work-objects/ - */ +/** @see https://docs.slack.dev/messaging/work-objects/ */ import type { EntityMetadata, @@ -14,35 +7,26 @@ import type { } from "@slack/types"; import type { tasks } from "@superset/db/schema"; -// Superset branding -const SUPERSET_PRODUCT_NAME = "Superset"; +import { env } from "@/env"; -// Web app URL (for unfurl matching and navigation) -// TODO: Use env var in production -const WEB_APP_URL = "https://app.superset.sh"; +const SUPERSET_PRODUCT_NAME = "Superset"; -// Task with relations from DB query (minimal for unfurl preview) type TaskWithRelations = typeof tasks.$inferSelect & { status?: { id: string; name: string } | null; assignee?: { id: string; name: string | null; email: string } | null; }; -// Task with full relations for flexpane type TaskWithFullRelations = TaskWithRelations & { creator?: { id: string; name: string | null; email: string } | null; organization?: { id: string; name: string; slug: string } | null; }; -/** - * Creates a Work Object entity for a task. - */ export function createTaskWorkObject(task: TaskWithRelations): EntityMetadata { - const taskUrl = `${WEB_APP_URL}/tasks/${task.slug}`; + const taskUrl = `${env.NEXT_PUBLIC_WEB_URL}/tasks/${task.slug}`; const fields: TaskEntityFields = {}; const displayOrder: string[] = []; - // Status field (always shown) fields.status = { // Padded for spacing in Slack value: task.status @@ -51,7 +35,6 @@ export function createTaskWorkObject(task: TaskWithRelations): EntityMetadata { }; displayOrder.push("status"); - // Assignee field (conditional) if (task.assignee) { fields.assignee = { type: "slack#/types/user", @@ -92,32 +75,26 @@ export function createTaskWorkObject(task: TaskWithRelations): EntityMetadata { }; } -/** - * Creates a Work Object entity for the flexpane (details view). - * Includes all task fields for the expanded side panel. - */ +/** Includes all task fields for the expanded flexpane side panel. */ export function createTaskFlexpaneObject( task: TaskWithFullRelations, ): EntityMetadata { - const taskUrl = `${WEB_APP_URL}/tasks/${task.slug}`; + const taskUrl = `${env.NEXT_PUBLIC_WEB_URL}/tasks/${task.slug}`; const fields: TaskEntityFields = {}; const displayOrder: string[] = []; - // Description field fields.description = { value: task.description || "No description", format: "markdown", }; displayOrder.push("description"); - // Status field fields.status = { value: task.status?.name ?? "No status", }; displayOrder.push("status"); - // Assignee field if (task.assignee) { fields.assignee = { type: "slack#/types/user", @@ -135,7 +112,6 @@ export function createTaskFlexpaneObject( } displayOrder.push("assignee"); - // Priority field const priorityValue = formatPriorityLabel(task.priority); fields.priority = task.priority === "none" @@ -143,7 +119,6 @@ export function createTaskFlexpaneObject( : { value: priorityValue }; displayOrder.push("priority"); - // Custom fields for additional details const customFields: Array<{ key: string; label: string; @@ -153,7 +128,6 @@ export function createTaskFlexpaneObject( user?: { text: string; email?: string }; }> = []; - // Labels const labels = task.labels as string[] | null; customFields.push( labels && labels.length > 0 @@ -172,7 +146,6 @@ export function createTaskFlexpaneObject( }, ); - // Organization customFields.push({ key: "organization", label: "Organization", @@ -180,7 +153,6 @@ export function createTaskFlexpaneObject( value: task.organization?.name ?? "—", }); - // Created by if (task.creator) { customFields.push({ key: "created_by", @@ -201,7 +173,6 @@ export function createTaskFlexpaneObject( }); } - // Created date customFields.push({ key: "created", label: "Created", @@ -209,7 +180,6 @@ export function createTaskFlexpaneObject( value: Math.floor(new Date(task.createdAt).getTime() / 1000), }); - // Updated date customFields.push({ key: "updated", label: "Updated", @@ -257,8 +227,6 @@ export function createTaskFlexpaneObject( }; } -// Helper functions - function formatPriorityLabel(priority: string): string { const labels: Record = { urgent: "Urgent", @@ -270,23 +238,18 @@ function formatPriorityLabel(priority: string): string { return labels[priority] ?? priority; } -// URL parsing utilities - /** - * Extract task slug from Superset URL. * Supports: - * - /api/integrations/slack/tasks/my-task-slug (legacy API format) * - /tasks/my-task-slug (web app format) + * - /api/integrations/slack/tasks/my-task-slug (legacy API format) */ export function parseTaskSlugFromUrl(url: string): string | null { try { const parsed = new URL(url); - // Try web app format first: /tasks/{slug} const webMatch = parsed.pathname.match(/^\/tasks\/([^/]+)/); if (webMatch?.[1]) { return webMatch[1]; } - // Fall back to legacy API format: /api/integrations/slack/tasks/{slug} const apiMatch = parsed.pathname.match( /^\/api\/integrations\/slack\/tasks\/([^/]+)/, ); diff --git a/apps/api/src/app/api/integrations/slack/jobs/process-mention/route.ts b/apps/api/src/app/api/integrations/slack/jobs/process-mention/route.ts index 70d1f6f7c01..6d2505bbd26 100644 --- a/apps/api/src/app/api/integrations/slack/jobs/process-mention/route.ts +++ b/apps/api/src/app/api/integrations/slack/jobs/process-mention/route.ts @@ -27,23 +27,18 @@ export async function POST(request: Request) { const body = await request.text(); const signature = request.headers.get("upstash-signature"); - // Skip signature verification in development - const isDev = env.NODE_ENV === "development"; - - if (!isDev) { - if (!signature) { - return Response.json({ error: "Missing signature" }, { status: 401 }); - } - - const isValid = await receiver.verify({ - body, - signature, - url: `${env.NEXT_PUBLIC_API_URL}/api/integrations/slack/jobs/process-mention`, - }); - - if (!isValid) { - return Response.json({ error: "Invalid signature" }, { status: 401 }); - } + if (!signature) { + return Response.json({ error: "Missing signature" }, { status: 401 }); + } + + const isValid = await receiver.verify({ + body, + signature, + url: `${env.NEXT_PUBLIC_API_URL}/api/integrations/slack/jobs/process-mention`, + }); + + if (!isValid) { + return Response.json({ error: "Invalid signature" }, { status: 401 }); } const parsed = payloadSchema.safeParse(JSON.parse(body)); From 9dcde4a36046645bc368e7cc9fd6218cc53cdb37 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Thu, 29 Jan 2026 23:34:35 -0800 Subject: [PATCH 8/8] fix(docs): handle ReactNode type for fumadocs page tree node names node.name is typed as ReactNode (nullable, non-string) in fumadocs. Convert to string before calling .match() and assigning to title. --- .../Sidebar/components/SidebarContent/SidebarContent.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/docs/src/app/(docs)/components/Sidebar/components/SidebarContent/SidebarContent.tsx b/apps/docs/src/app/(docs)/components/Sidebar/components/SidebarContent/SidebarContent.tsx index 4c21031b895..71c1ed89633 100644 --- a/apps/docs/src/app/(docs)/components/Sidebar/components/SidebarContent/SidebarContent.tsx +++ b/apps/docs/src/app/(docs)/components/Sidebar/components/SidebarContent/SidebarContent.tsx @@ -32,8 +32,8 @@ function parseSections(): SidebarSection[] { for (const node of pageTree.children) { if (node.type === "separator") { - // Parse separator format: "IconName Title" - const match = node.name.match(/^(\w+)\s+(.+)$/); + const name = String(node.name ?? ""); + const match = name.match(/^(\w+)\s+(.+)$/); if (match) { const [, iconName, title] = match; currentSection = { @@ -45,7 +45,7 @@ function parseSections(): SidebarSection[] { } } else if (node.type === "page" && currentSection) { currentSection.items.push({ - title: node.name, + title: String(node.name ?? ""), href: node.url, }); }