From 0e78b14d2a8c26810d8d1fedf26ec9f79444cb53 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 13 Aug 2026 18:11:32 -0700 Subject: [PATCH] Support Slack bot connection flow --- .../special-tags/special-tags.test.tsx | 15 +++ .../components/special-tags/special-tags.tsx | 11 +- .../lib/copilot/generated/tool-catalog-v1.ts | 33 ++++++ .../lib/copilot/generated/tool-schemas-v1.ts | 28 +++++ .../tool-executor/register-handlers.ts | 3 + .../copilot/tools/client/store-utils.test.ts | 32 +++++- .../lib/copilot/tools/client/store-utils.ts | 34 +++++- .../management/connect-slack-bot.test.ts | 105 ++++++++++++++++++ .../handlers/management/connect-slack-bot.ts | 91 +++++++++++++++ apps/sim/lib/copilot/tools/tool-display.ts | 2 + 10 files changed, 348 insertions(+), 6 deletions(-) create mode 100644 apps/sim/lib/copilot/tools/handlers/management/connect-slack-bot.test.ts create mode 100644 apps/sim/lib/copilot/tools/handlers/management/connect-slack-bot.ts diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx index 6270761fc4..b2ba0064be 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx @@ -1359,4 +1359,19 @@ describe('recoverTrailingBareOptions', () => { const { segments } = parseSpecialTags(`Pick one ${bareOptions}`, false) expect(segments.filter((segment) => segment.type === 'options')).toHaveLength(1) }) + + it('recovers an options payload wrapped in the singular `, + false + ) + const last = segments[segments.length - 1] + expect(last.type).toBe('options') + if (last.type === 'options') { + expect(Object.keys(last.data)).toEqual(['1', '2', '3']) + expect(last.data['1']?.title).toBe('Demo wait — sleep until an agent finishes') + } + }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx index 21596a05ea..ceb79140ce 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx @@ -1459,11 +1459,20 @@ export function parseSpecialTags(content: string, isStreaming: boolean): ParsedS * already parsed. Never applied mid-stream: a partial JSON tail must not * flicker between prose and a card. */ +const NEAR_MISS_OPTIONS_WRAPPER = /` fails + // the brace gate below). Unwrap it and let the strict shape check decide. + const nearMiss = NEAR_MISS_OPTIONS_WRAPPER.exec(text) + if (nearMiss) { + text = `${text.slice(0, nearMiss.index)}${nearMiss[1]}` + } if (!text.trimEnd().endsWith('}')) return // The payload nests objects, so the START brace is the first one from which // the remainder parses — probe brace positions left to right (bounded). diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index da266e91fd..fe635a1bc9 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -35,6 +35,7 @@ export interface ToolCatalogEntry { | 'browser_type' | 'browser_wait_for' | 'call_integration_tool' + | 'connect_slack_bot' | 'cp' | 'create_empty_file' | 'create_workflow' @@ -163,6 +164,7 @@ export interface ToolCatalogEntry { | 'browser_type' | 'browser_wait_for' | 'call_integration_tool' + | 'connect_slack_bot' | 'cp' | 'create_empty_file' | 'create_workflow' @@ -1636,6 +1638,36 @@ export const CallIntegrationTool: ToolCatalogEntry = { requiresApproval: true, } +export const ConnectSlackBot: ToolCatalogEntry = { + id: 'connect_slack_bot', + name: 'connect_slack_bot', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + botTokenEnvVar: { + type: 'string', + description: + 'NAME of the environment variable holding the bot token (xoxb-..., OAuth & Permissions → Bot User OAuth Token). Pass the variable name, never the token value.', + }, + description: { type: 'string', description: 'Optional description shown on the credential.' }, + displayName: { + type: 'string', + description: + 'Display name for the credential, shown in the credential picker (e.g. "Elder Bot"). Must be unique in the workspace.', + }, + signingSecretEnvVar: { + type: 'string', + description: + "NAME of the environment variable holding the Slack app's signing secret (Basic Information → App Credentials). Pass the variable name, never the secret value.", + }, + }, + required: ['displayName', 'signingSecretEnvVar', 'botTokenEnvVar'], + }, + requiredPermission: 'write', +} + export const Cp: ToolCatalogEntry = { id: 'cp', name: 'cp', @@ -7103,6 +7135,7 @@ export const TOOL_CATALOG: Record = { [BrowserType.id]: BrowserType, [BrowserWaitFor.id]: BrowserWaitFor, [CallIntegrationTool.id]: CallIntegrationTool, + [ConnectSlackBot.id]: ConnectSlackBot, [Cp.id]: Cp, [CreateEmptyFile.id]: CreateEmptyFile, [CreateWorkflow.id]: CreateWorkflow, diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index 105022d971..d94d43860e 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -1580,6 +1580,34 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, + connect_slack_bot: { + parameters: { + type: 'object', + properties: { + botTokenEnvVar: { + type: 'string', + description: + 'NAME of the environment variable holding the bot token (xoxb-..., OAuth & Permissions → Bot User OAuth Token). Pass the variable name, never the token value.', + }, + description: { + type: 'string', + description: 'Optional description shown on the credential.', + }, + displayName: { + type: 'string', + description: + 'Display name for the credential, shown in the credential picker (e.g. "Elder Bot"). Must be unique in the workspace.', + }, + signingSecretEnvVar: { + type: 'string', + description: + "NAME of the environment variable holding the Slack app's signing secret (Basic Information → App Credentials). Pass the variable name, never the secret value.", + }, + }, + required: ['displayName', 'signingSecretEnvVar', 'botTokenEnvVar'], + }, + resultSchema: undefined, + }, cp: { parameters: { type: 'object', diff --git a/apps/sim/lib/copilot/tool-executor/register-handlers.ts b/apps/sim/lib/copilot/tool-executor/register-handlers.ts index 61276eb752..b78eac3859 100644 --- a/apps/sim/lib/copilot/tool-executor/register-handlers.ts +++ b/apps/sim/lib/copilot/tool-executor/register-handlers.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { + ConnectSlackBot, Cp as CpTool, CreateWorkflow, CreateWorkspaceMcpServer, @@ -74,6 +75,7 @@ import { } from '../tools/handlers/deployment/manage' import { executeFunctionExecute } from '../tools/handlers/function-execute' import { executeListIntegrationTools } from '../tools/handlers/integration-tools' +import { executeConnectSlackBot } from '../tools/handlers/management/connect-slack-bot' import { executeManageCredential } from '../tools/handlers/management/manage-credential' import { executeManageCustomTool } from '../tools/handlers/management/manage-custom-tool' import { executeManageMcpTool } from '../tools/handlers/management/manage-mcp-tool' @@ -185,6 +187,7 @@ function buildHandlerMap(): Record { [ManageSandbox.id]: h(executeManageSandbox), [ManageSkill.id]: h(executeManageSkill), [ManageCredential.id]: h(executeManageCredential), + [ConnectSlackBot.id]: h(executeConnectSlackBot), [OauthGetAuthLink.id]: h(executeOAuthGetAuthLink), // Rolling-deploy compatibility for calls/checkpoints created before OAuth // moved into terminal credential cards. New agents no longer receive this diff --git a/apps/sim/lib/copilot/tools/client/store-utils.test.ts b/apps/sim/lib/copilot/tools/client/store-utils.test.ts index 788b42781e..81fa287064 100644 --- a/apps/sim/lib/copilot/tools/client/store-utils.test.ts +++ b/apps/sim/lib/copilot/tools/client/store-utils.test.ts @@ -40,7 +40,7 @@ describe('resolveToolDisplay', () => { resolveToolDisplay(ReadTool.id, ClientToolCallState.success, { path: 'workflows/My Workflow/meta.json', })?.text - ).toBe('Read My Workflow') + ).toBe('Read metadata for My Workflow') expect( resolveToolDisplay(ReadTool.id, ClientToolCallState.success, { @@ -49,6 +49,34 @@ describe('resolveToolDisplay', () => { ).toBe('Read RET XYZ') }) + it('labels resource artifact reads distinctly instead of repeating the resource name', () => { + expect( + resolveToolDisplay(ReadTool.id, ClientToolCallState.success, { + path: 'workflows/Elder v2/The Elder/state.json', + })?.text + ).toBe('Read The Elder') + expect( + resolveToolDisplay(ReadTool.id, ClientToolCallState.success, { + path: 'workflows/Elder v2/The Elder/deployment.json', + })?.text + ).toBe('Read deployment status for The Elder') + expect( + resolveToolDisplay(ReadTool.id, ClientToolCallState.error, { + path: 'workflows/Elder v2/The Elder/lint.json', + })?.text + ).toBe('Attempted to read lint results for The Elder') + expect( + resolveToolDisplay(ReadTool.id, ClientToolCallState.success, { + path: 'tables/CRM/Leads/views.json', + })?.text + ).toBe('Read views of Leads') + expect( + resolveToolDisplay(ReadTool.id, ClientToolCallState.success, { + path: 'knowledgebases/Contracts/documents.json', + })?.text + ).toBe('Read documents in Contracts') + }) + it('decodes percent-encoded VFS path segments for display', () => { expect( resolveToolDisplay(ReadTool.id, ClientToolCallState.executing, { @@ -60,7 +88,7 @@ describe('resolveToolDisplay', () => { resolveToolDisplay(ReadTool.id, ClientToolCallState.success, { path: 'workflows/My%20Workflow/meta.json', })?.text - ).toBe('Read My Workflow') + ).toBe('Read metadata for My Workflow') expect( resolveToolDisplay(ReadTool.id, ClientToolCallState.executing, { diff --git a/apps/sim/lib/copilot/tools/client/store-utils.ts b/apps/sim/lib/copilot/tools/client/store-utils.ts index 343c9e2712..2efa7d43fe 100644 --- a/apps/sim/lib/copilot/tools/client/store-utils.ts +++ b/apps/sim/lib/copilot/tools/client/store-utils.ts @@ -107,11 +107,39 @@ function describeReadTarget(path: string | undefined): string | undefined { } if (resourceType === 'workflow') { - return stripExtension(getLeafResourceSegment(segments)) + return describeResourceArtifactTarget(segments) } - const resourceName = segments[1] || segments[segments.length - 1] - return stripExtension(resourceName) + return describeResourceArtifactTarget(segments) +} + +/** + * Resource-scoped artifact files, labeled the same prefix way as + * FILE_FACET_LABELS. `state.json` is the empty facet — reading a workflow means + * reading its state — so "Read The Elder", "Read metadata for The Elder", and + * "Read deployment status for The Elder" render as three distinct rows instead + * of three identical "Read The Elder" lines. + */ +const RESOURCE_ARTIFACT_LABELS: Record = { + 'state.json': '', + 'meta.json': 'metadata for', + 'lint.json': 'lint results for', + 'deployment.json': 'deployment status for', + 'versions.json': 'versions of', + 'executions.json': 'runs of', + 'views.json': 'views of', + 'documents.json': 'documents in', + 'connectors.json': 'connectors of', +} + +function describeResourceArtifactTarget(segments: string[]): string { + const lastSegment = segments[segments.length - 1] || '' + const resourceName = stripExtension(getLeafResourceSegment(segments)) + const artifactLabel = RESOURCE_ARTIFACT_LABELS[lastSegment] + if (artifactLabel !== undefined && segments.length > 1) { + return artifactLabel ? `${artifactLabel} ${resourceName}` : resourceName + } + return resourceName } // A workspace file is addressed as a directory of facets in the VFS diff --git a/apps/sim/lib/copilot/tools/handlers/management/connect-slack-bot.test.ts b/apps/sim/lib/copilot/tools/handlers/management/connect-slack-bot.test.ts new file mode 100644 index 0000000000..46dc0b0dc0 --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/management/connect-slack-bot.test.ts @@ -0,0 +1,105 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + performCreateCredential: vi.fn(), + getEffectiveDecryptedEnv: vi.fn(), +})) + +vi.mock('@/lib/credentials/orchestration', () => ({ + performCreateCredential: mocks.performCreateCredential, +})) +vi.mock('@/lib/environment/utils', () => ({ + getEffectiveDecryptedEnv: mocks.getEffectiveDecryptedEnv, +})) + +import { executeConnectSlackBot } from './connect-slack-bot' + +const context = { userId: 'user-1', workspaceId: 'ws-1' } as never + +const validParams = { + displayName: 'Elder Bot', + signingSecretEnvVar: 'SLACK_SIGNING_SECRET', + botTokenEnvVar: 'SLACK_BOT_TOKEN', +} + +beforeEach(() => { + vi.clearAllMocks() + mocks.getEffectiveDecryptedEnv.mockResolvedValue({ + SLACK_SIGNING_SECRET: 'shhh', + SLACK_BOT_TOKEN: 'xoxb-123', + }) + mocks.performCreateCredential.mockResolvedValue({ + success: true, + created: true, + credential: { id: 'cred-1', displayName: 'Elder Bot' }, + }) +}) + +describe('executeConnectSlackBot', () => { + it('resolves env vars server-side and mints the credential with the request URL', async () => { + const result = await executeConnectSlackBot(validParams, context) + + expect(mocks.performCreateCredential).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'ws-1', + userId: 'user-1', + type: 'service_account', + providerId: 'slack-custom-bot', + displayName: 'Elder Bot', + signingSecret: 'shhh', + botToken: 'xoxb-123', + }) + ) + expect(result.success).toBe(true) + expect(result.output).toMatchObject({ + credentialId: 'cred-1', + created: true, + requestUrl: expect.stringContaining('/api/webhooks/slack/custom/cred-1'), + }) + }) + + it('names the missing env vars without leaking any values', async () => { + mocks.getEffectiveDecryptedEnv.mockResolvedValue({ SLACK_SIGNING_SECRET: 'shhh' }) + + const result = await executeConnectSlackBot(validParams, context) + + expect(result.success).toBe(false) + expect(result.error).toContain('SLACK_BOT_TOKEN') + expect(result.error).not.toContain('shhh') + expect(mocks.performCreateCredential).not.toHaveBeenCalled() + }) + + it('requires displayName and both env var names', async () => { + const missingName = await executeConnectSlackBot( + { signingSecretEnvVar: 'A', botTokenEnvVar: 'B' }, + context + ) + expect(missingName.success).toBe(false) + expect(missingName.error).toContain('displayName') + + const missingVars = await executeConnectSlackBot({ displayName: 'Bot' }, context) + expect(missingVars.success).toBe(false) + expect(missingVars.error).toContain('signingSecretEnvVar') + }) + + it('surfaces orchestration failures (e.g. auth.test rejection or name conflict)', async () => { + mocks.performCreateCredential.mockResolvedValue({ + success: false, + error: 'Slack rejected the bot token', + }) + + const result = await executeConnectSlackBot(validParams, context) + + expect(result.success).toBe(false) + expect(result.error).toContain('Slack rejected the bot token') + }) + + it('requires workspace scope', async () => { + const result = await executeConnectSlackBot(validParams, { userId: 'user-1' } as never) + expect(result.success).toBe(false) + expect(result.error).toContain('Workspace') + }) +}) diff --git a/apps/sim/lib/copilot/tools/handlers/management/connect-slack-bot.ts b/apps/sim/lib/copilot/tools/handlers/management/connect-slack-bot.ts new file mode 100644 index 0000000000..f02599e8d8 --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/management/connect-slack-bot.ts @@ -0,0 +1,91 @@ +import { toError } from '@sim/utils/errors' +import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' +import { performCreateCredential } from '@/lib/credentials/orchestration' +import { getEffectiveDecryptedEnv } from '@/lib/environment/utils' +import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types' +import { buildSlackCustomBotRequestUrl } from '@/triggers/webhook-url' + +/** + * Mints a reusable Slack custom-bot credential from secrets ALREADY stored as + * environment variables (a v1 setup being migrated, or values saved via + * set_environment_variables after a browser-agent extraction). The agent + * passes env-var NAMES; the values are resolved here and validated by the + * credential orchestration (Slack auth.test), so no secret ever appears in + * tool args, checkpoints, or transcripts. When the USER holds the secrets, + * the service_account credential card is the right path instead. + */ +export function executeConnectSlackBot( + rawParams: Record, + context: ExecutionContext +): Promise { + const params = rawParams as { + displayName?: string + description?: string + signingSecretEnvVar?: string + botTokenEnvVar?: string + } + return (async () => { + try { + if (!context?.userId) { + return { success: false, error: 'Authentication required' } + } + const workspaceId = context.workspaceId + if (!workspaceId) { + return { success: false, error: 'Workspace scope required' } + } + const { displayName, description, signingSecretEnvVar, botTokenEnvVar } = params + if (!displayName) { + return { success: false, error: 'displayName is required' } + } + if (!signingSecretEnvVar || !botTokenEnvVar) { + return { + success: false, + error: + 'signingSecretEnvVar and botTokenEnvVar are required: the NAMES of the environment variables holding the Slack signing secret and bot token. Save the values with set_environment_variables first if needed.', + } + } + + const env = await getEffectiveDecryptedEnv(context.userId, workspaceId) + const missing = [signingSecretEnvVar, botTokenEnvVar].filter((name) => !env[name]) + if (missing.length > 0) { + return { + success: false, + error: `Environment variable(s) not found: ${missing.join(', ')}. Check environment/ in the VFS, or save the values with set_environment_variables first.`, + } + } + + const result = await performCreateCredential({ + workspaceId, + userId: context.userId, + type: 'service_account', + providerId: SLACK_CUSTOM_BOT_PROVIDER_ID, + displayName, + description, + signingSecret: env[signingSecretEnvVar], + botToken: env[botTokenEnvVar], + }) + if (!result.success || !result.credential) { + return { + success: false, + error: + result.error || + 'Failed to connect the Slack custom bot. If a credential with this display name already exists, reuse it (environment/credentials.json) or pick a different name.', + } + } + return { + success: true, + output: { + credentialId: result.credential.id, + displayName: result.credential.displayName, + created: result.created !== false, + // The Slack app's Event Subscriptions Request URL — one per + // credential, shared by every trigger that selects it; live + // immediately, no deployment needed. + requestUrl: buildSlackCustomBotRequestUrl(result.credential.id), + }, + } + } catch (error) { + return { success: false, error: toError(error).message } + } + })() +} diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index 8f1fcc8343..4059797c33 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -492,6 +492,7 @@ const TOOL_TITLES: Record = { list_workspace_mcp_servers: 'Listing MCP servers', load_deployment: 'Loading deployment', save_upload: 'Saving upload', + connect_slack_bot: 'Connecting Slack bot', manage_sandbox: 'Managing sandbox', manage_scheduled_task: 'Managing scheduled task', move_file: 'Moving file', @@ -1034,6 +1035,7 @@ const COMPLETED_VERB_REWRITES: Record = { Crawling: 'Crawled', Creating: 'Created', Deleting: 'Deleted', + Connecting: 'Connected', Deploying: 'Deployed', Dragging: 'Dragged', Inserting: 'Inserted',