Support Slack bot connection flow

This commit is contained in:
Siddharth Ganesan
2026-08-13 18:11:32 -07:00
parent 752ddd258f
commit 0e78b14d2a
10 changed files with 348 additions and 6 deletions
@@ -1359,4 +1359,19 @@ describe('recoverTrailingBareOptions', () => {
const { segments } = parseSpecialTags(`Pick one <options>${bareOptions}</options>`, false)
expect(segments.filter((segment) => segment.type === 'options')).toHaveLength(1)
})
it('recovers an options payload wrapped in the singular <option> near-miss tag', () => {
const body =
'{"1": {"title": "Demo wait — sleep until an agent finishes", "description": "wait_agents demo"}, "2": {"title": "Demo steer — redirect a running agent mid-task", "description": "steer_agent demo"}, "3": {"title": "Demo stop — interrupt a running agent", "description": "interrupt_agent demo"}}'
const { segments } = parseSpecialTags(
`Next up is usually **wait**, **steer**, or **stop**.\n\n<option>${body}</option>`,
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')
}
})
})
@@ -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 = /<option>\s*(\{[\s\S]*\})\s*<\/option>\s*$/
function recoverTrailingBareOptions(segments: ContentSegment[]): void {
const last = segments[segments.length - 1]
if (!last || last.type !== 'text') return
if (segments.some((segment) => segment.type === 'options')) return
const text = last.content
let text = last.content
// A near-miss wrapper — the singular `<option>` tag observed in the wild —
// is neither a parseable tag nor bare JSON (the trailing `</option>` 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).
@@ -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<string, ToolCatalogEntry> = {
[BrowserType.id]: BrowserType,
[BrowserWaitFor.id]: BrowserWaitFor,
[CallIntegrationTool.id]: CallIntegrationTool,
[ConnectSlackBot.id]: ConnectSlackBot,
[Cp.id]: Cp,
[CreateEmptyFile.id]: CreateEmptyFile,
[CreateWorkflow.id]: CreateWorkflow,
@@ -1580,6 +1580,34 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
},
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',
@@ -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<string, ToolHandler> {
[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
@@ -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, {
@@ -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<string, string> = {
'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
@@ -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')
})
})
@@ -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<string, unknown>,
context: ExecutionContext
): Promise<ToolCallResult> {
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 }
}
})()
}
@@ -492,6 +492,7 @@ const TOOL_TITLES: Record<string, string> = {
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<string, string> = {
Crawling: 'Crawled',
Creating: 'Created',
Deleting: 'Deleted',
Connecting: 'Connected',
Deploying: 'Deployed',
Dragging: 'Dragged',
Inserting: 'Inserted',