mirror of
https://github.com/simstudioai/sim.git
synced 2026-10-30 19:32:46 +08:00
Support Slack bot connection flow
This commit is contained in:
+15
@@ -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')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
+10
-1
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user