Skip to content

Commit 0e78b14

Browse files
committed
Support Slack bot connection flow
1 parent 752ddd2 commit 0e78b14

10 files changed

Lines changed: 348 additions & 6 deletions

File tree

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1359,4 +1359,19 @@ describe('recoverTrailingBareOptions', () => {
13591359
const { segments } = parseSpecialTags(`Pick one <options>${bareOptions}</options>`, false)
13601360
expect(segments.filter((segment) => segment.type === 'options')).toHaveLength(1)
13611361
})
1362+
1363+
it('recovers an options payload wrapped in the singular <option> near-miss tag', () => {
1364+
const body =
1365+
'{"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"}}'
1366+
const { segments } = parseSpecialTags(
1367+
`Next up is usually **wait**, **steer**, or **stop**.\n\n<option>${body}</option>`,
1368+
false
1369+
)
1370+
const last = segments[segments.length - 1]
1371+
expect(last.type).toBe('options')
1372+
if (last.type === 'options') {
1373+
expect(Object.keys(last.data)).toEqual(['1', '2', '3'])
1374+
expect(last.data['1']?.title).toBe('Demo wait — sleep until an agent finishes')
1375+
}
1376+
})
13621377
})

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1459,11 +1459,20 @@ export function parseSpecialTags(content: string, isStreaming: boolean): ParsedS
14591459
* already parsed. Never applied mid-stream: a partial JSON tail must not
14601460
* flicker between prose and a card.
14611461
*/
1462+
const NEAR_MISS_OPTIONS_WRAPPER = /<option>\s*(\{[\s\S]*\})\s*<\/option>\s*$/
1463+
14621464
function recoverTrailingBareOptions(segments: ContentSegment[]): void {
14631465
const last = segments[segments.length - 1]
14641466
if (!last || last.type !== 'text') return
14651467
if (segments.some((segment) => segment.type === 'options')) return
1466-
const text = last.content
1468+
let text = last.content
1469+
// A near-miss wrapper — the singular `<option>` tag observed in the wild —
1470+
// is neither a parseable tag nor bare JSON (the trailing `</option>` fails
1471+
// the brace gate below). Unwrap it and let the strict shape check decide.
1472+
const nearMiss = NEAR_MISS_OPTIONS_WRAPPER.exec(text)
1473+
if (nearMiss) {
1474+
text = `${text.slice(0, nearMiss.index)}${nearMiss[1]}`
1475+
}
14671476
if (!text.trimEnd().endsWith('}')) return
14681477
// The payload nests objects, so the START brace is the first one from which
14691478
// the remainder parses — probe brace positions left to right (bounded).

apps/sim/lib/copilot/generated/tool-catalog-v1.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export interface ToolCatalogEntry {
3535
| 'browser_type'
3636
| 'browser_wait_for'
3737
| 'call_integration_tool'
38+
| 'connect_slack_bot'
3839
| 'cp'
3940
| 'create_empty_file'
4041
| 'create_workflow'
@@ -163,6 +164,7 @@ export interface ToolCatalogEntry {
163164
| 'browser_type'
164165
| 'browser_wait_for'
165166
| 'call_integration_tool'
167+
| 'connect_slack_bot'
166168
| 'cp'
167169
| 'create_empty_file'
168170
| 'create_workflow'
@@ -1636,6 +1638,36 @@ export const CallIntegrationTool: ToolCatalogEntry = {
16361638
requiresApproval: true,
16371639
}
16381640

1641+
export const ConnectSlackBot: ToolCatalogEntry = {
1642+
id: 'connect_slack_bot',
1643+
name: 'connect_slack_bot',
1644+
route: 'sim',
1645+
mode: 'async',
1646+
parameters: {
1647+
type: 'object',
1648+
properties: {
1649+
botTokenEnvVar: {
1650+
type: 'string',
1651+
description:
1652+
'NAME of the environment variable holding the bot token (xoxb-..., OAuth & Permissions → Bot User OAuth Token). Pass the variable name, never the token value.',
1653+
},
1654+
description: { type: 'string', description: 'Optional description shown on the credential.' },
1655+
displayName: {
1656+
type: 'string',
1657+
description:
1658+
'Display name for the credential, shown in the credential picker (e.g. "Elder Bot"). Must be unique in the workspace.',
1659+
},
1660+
signingSecretEnvVar: {
1661+
type: 'string',
1662+
description:
1663+
"NAME of the environment variable holding the Slack app's signing secret (Basic Information → App Credentials). Pass the variable name, never the secret value.",
1664+
},
1665+
},
1666+
required: ['displayName', 'signingSecretEnvVar', 'botTokenEnvVar'],
1667+
},
1668+
requiredPermission: 'write',
1669+
}
1670+
16391671
export const Cp: ToolCatalogEntry = {
16401672
id: 'cp',
16411673
name: 'cp',
@@ -7103,6 +7135,7 @@ export const TOOL_CATALOG: Record<string, ToolCatalogEntry> = {
71037135
[BrowserType.id]: BrowserType,
71047136
[BrowserWaitFor.id]: BrowserWaitFor,
71057137
[CallIntegrationTool.id]: CallIntegrationTool,
7138+
[ConnectSlackBot.id]: ConnectSlackBot,
71067139
[Cp.id]: Cp,
71077140
[CreateEmptyFile.id]: CreateEmptyFile,
71087141
[CreateWorkflow.id]: CreateWorkflow,

apps/sim/lib/copilot/generated/tool-schemas-v1.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1580,6 +1580,34 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
15801580
},
15811581
resultSchema: undefined,
15821582
},
1583+
connect_slack_bot: {
1584+
parameters: {
1585+
type: 'object',
1586+
properties: {
1587+
botTokenEnvVar: {
1588+
type: 'string',
1589+
description:
1590+
'NAME of the environment variable holding the bot token (xoxb-..., OAuth & Permissions → Bot User OAuth Token). Pass the variable name, never the token value.',
1591+
},
1592+
description: {
1593+
type: 'string',
1594+
description: 'Optional description shown on the credential.',
1595+
},
1596+
displayName: {
1597+
type: 'string',
1598+
description:
1599+
'Display name for the credential, shown in the credential picker (e.g. "Elder Bot"). Must be unique in the workspace.',
1600+
},
1601+
signingSecretEnvVar: {
1602+
type: 'string',
1603+
description:
1604+
"NAME of the environment variable holding the Slack app's signing secret (Basic Information → App Credentials). Pass the variable name, never the secret value.",
1605+
},
1606+
},
1607+
required: ['displayName', 'signingSecretEnvVar', 'botTokenEnvVar'],
1608+
},
1609+
resultSchema: undefined,
1610+
},
15831611
cp: {
15841612
parameters: {
15851613
type: 'object',

apps/sim/lib/copilot/tool-executor/register-handlers.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createLogger } from '@sim/logger'
22
import {
3+
ConnectSlackBot,
34
Cp as CpTool,
45
CreateWorkflow,
56
CreateWorkspaceMcpServer,
@@ -74,6 +75,7 @@ import {
7475
} from '../tools/handlers/deployment/manage'
7576
import { executeFunctionExecute } from '../tools/handlers/function-execute'
7677
import { executeListIntegrationTools } from '../tools/handlers/integration-tools'
78+
import { executeConnectSlackBot } from '../tools/handlers/management/connect-slack-bot'
7779
import { executeManageCredential } from '../tools/handlers/management/manage-credential'
7880
import { executeManageCustomTool } from '../tools/handlers/management/manage-custom-tool'
7981
import { executeManageMcpTool } from '../tools/handlers/management/manage-mcp-tool'
@@ -185,6 +187,7 @@ function buildHandlerMap(): Record<string, ToolHandler> {
185187
[ManageSandbox.id]: h(executeManageSandbox),
186188
[ManageSkill.id]: h(executeManageSkill),
187189
[ManageCredential.id]: h(executeManageCredential),
190+
[ConnectSlackBot.id]: h(executeConnectSlackBot),
188191
[OauthGetAuthLink.id]: h(executeOAuthGetAuthLink),
189192
// Rolling-deploy compatibility for calls/checkpoints created before OAuth
190193
// moved into terminal credential cards. New agents no longer receive this

apps/sim/lib/copilot/tools/client/store-utils.test.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ describe('resolveToolDisplay', () => {
4040
resolveToolDisplay(ReadTool.id, ClientToolCallState.success, {
4141
path: 'workflows/My Workflow/meta.json',
4242
})?.text
43-
).toBe('Read My Workflow')
43+
).toBe('Read metadata for My Workflow')
4444

4545
expect(
4646
resolveToolDisplay(ReadTool.id, ClientToolCallState.success, {
@@ -49,6 +49,34 @@ describe('resolveToolDisplay', () => {
4949
).toBe('Read RET XYZ')
5050
})
5151

52+
it('labels resource artifact reads distinctly instead of repeating the resource name', () => {
53+
expect(
54+
resolveToolDisplay(ReadTool.id, ClientToolCallState.success, {
55+
path: 'workflows/Elder v2/The Elder/state.json',
56+
})?.text
57+
).toBe('Read The Elder')
58+
expect(
59+
resolveToolDisplay(ReadTool.id, ClientToolCallState.success, {
60+
path: 'workflows/Elder v2/The Elder/deployment.json',
61+
})?.text
62+
).toBe('Read deployment status for The Elder')
63+
expect(
64+
resolveToolDisplay(ReadTool.id, ClientToolCallState.error, {
65+
path: 'workflows/Elder v2/The Elder/lint.json',
66+
})?.text
67+
).toBe('Attempted to read lint results for The Elder')
68+
expect(
69+
resolveToolDisplay(ReadTool.id, ClientToolCallState.success, {
70+
path: 'tables/CRM/Leads/views.json',
71+
})?.text
72+
).toBe('Read views of Leads')
73+
expect(
74+
resolveToolDisplay(ReadTool.id, ClientToolCallState.success, {
75+
path: 'knowledgebases/Contracts/documents.json',
76+
})?.text
77+
).toBe('Read documents in Contracts')
78+
})
79+
5280
it('decodes percent-encoded VFS path segments for display', () => {
5381
expect(
5482
resolveToolDisplay(ReadTool.id, ClientToolCallState.executing, {
@@ -60,7 +88,7 @@ describe('resolveToolDisplay', () => {
6088
resolveToolDisplay(ReadTool.id, ClientToolCallState.success, {
6189
path: 'workflows/My%20Workflow/meta.json',
6290
})?.text
63-
).toBe('Read My Workflow')
91+
).toBe('Read metadata for My Workflow')
6492

6593
expect(
6694
resolveToolDisplay(ReadTool.id, ClientToolCallState.executing, {

apps/sim/lib/copilot/tools/client/store-utils.ts

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -107,11 +107,39 @@ function describeReadTarget(path: string | undefined): string | undefined {
107107
}
108108

109109
if (resourceType === 'workflow') {
110-
return stripExtension(getLeafResourceSegment(segments))
110+
return describeResourceArtifactTarget(segments)
111111
}
112112

113-
const resourceName = segments[1] || segments[segments.length - 1]
114-
return stripExtension(resourceName)
113+
return describeResourceArtifactTarget(segments)
114+
}
115+
116+
/**
117+
* Resource-scoped artifact files, labeled the same prefix way as
118+
* FILE_FACET_LABELS. `state.json` is the empty facet — reading a workflow means
119+
* reading its state — so "Read The Elder", "Read metadata for The Elder", and
120+
* "Read deployment status for The Elder" render as three distinct rows instead
121+
* of three identical "Read The Elder" lines.
122+
*/
123+
const RESOURCE_ARTIFACT_LABELS: Record<string, string> = {
124+
'state.json': '',
125+
'meta.json': 'metadata for',
126+
'lint.json': 'lint results for',
127+
'deployment.json': 'deployment status for',
128+
'versions.json': 'versions of',
129+
'executions.json': 'runs of',
130+
'views.json': 'views of',
131+
'documents.json': 'documents in',
132+
'connectors.json': 'connectors of',
133+
}
134+
135+
function describeResourceArtifactTarget(segments: string[]): string {
136+
const lastSegment = segments[segments.length - 1] || ''
137+
const resourceName = stripExtension(getLeafResourceSegment(segments))
138+
const artifactLabel = RESOURCE_ARTIFACT_LABELS[lastSegment]
139+
if (artifactLabel !== undefined && segments.length > 1) {
140+
return artifactLabel ? `${artifactLabel} ${resourceName}` : resourceName
141+
}
142+
return resourceName
115143
}
116144

117145
// A workspace file is addressed as a directory of facets in the VFS
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
const mocks = vi.hoisted(() => ({
7+
performCreateCredential: vi.fn(),
8+
getEffectiveDecryptedEnv: vi.fn(),
9+
}))
10+
11+
vi.mock('@/lib/credentials/orchestration', () => ({
12+
performCreateCredential: mocks.performCreateCredential,
13+
}))
14+
vi.mock('@/lib/environment/utils', () => ({
15+
getEffectiveDecryptedEnv: mocks.getEffectiveDecryptedEnv,
16+
}))
17+
18+
import { executeConnectSlackBot } from './connect-slack-bot'
19+
20+
const context = { userId: 'user-1', workspaceId: 'ws-1' } as never
21+
22+
const validParams = {
23+
displayName: 'Elder Bot',
24+
signingSecretEnvVar: 'SLACK_SIGNING_SECRET',
25+
botTokenEnvVar: 'SLACK_BOT_TOKEN',
26+
}
27+
28+
beforeEach(() => {
29+
vi.clearAllMocks()
30+
mocks.getEffectiveDecryptedEnv.mockResolvedValue({
31+
SLACK_SIGNING_SECRET: 'shhh',
32+
SLACK_BOT_TOKEN: 'xoxb-123',
33+
})
34+
mocks.performCreateCredential.mockResolvedValue({
35+
success: true,
36+
created: true,
37+
credential: { id: 'cred-1', displayName: 'Elder Bot' },
38+
})
39+
})
40+
41+
describe('executeConnectSlackBot', () => {
42+
it('resolves env vars server-side and mints the credential with the request URL', async () => {
43+
const result = await executeConnectSlackBot(validParams, context)
44+
45+
expect(mocks.performCreateCredential).toHaveBeenCalledWith(
46+
expect.objectContaining({
47+
workspaceId: 'ws-1',
48+
userId: 'user-1',
49+
type: 'service_account',
50+
providerId: 'slack-custom-bot',
51+
displayName: 'Elder Bot',
52+
signingSecret: 'shhh',
53+
botToken: 'xoxb-123',
54+
})
55+
)
56+
expect(result.success).toBe(true)
57+
expect(result.output).toMatchObject({
58+
credentialId: 'cred-1',
59+
created: true,
60+
requestUrl: expect.stringContaining('/api/webhooks/slack/custom/cred-1'),
61+
})
62+
})
63+
64+
it('names the missing env vars without leaking any values', async () => {
65+
mocks.getEffectiveDecryptedEnv.mockResolvedValue({ SLACK_SIGNING_SECRET: 'shhh' })
66+
67+
const result = await executeConnectSlackBot(validParams, context)
68+
69+
expect(result.success).toBe(false)
70+
expect(result.error).toContain('SLACK_BOT_TOKEN')
71+
expect(result.error).not.toContain('shhh')
72+
expect(mocks.performCreateCredential).not.toHaveBeenCalled()
73+
})
74+
75+
it('requires displayName and both env var names', async () => {
76+
const missingName = await executeConnectSlackBot(
77+
{ signingSecretEnvVar: 'A', botTokenEnvVar: 'B' },
78+
context
79+
)
80+
expect(missingName.success).toBe(false)
81+
expect(missingName.error).toContain('displayName')
82+
83+
const missingVars = await executeConnectSlackBot({ displayName: 'Bot' }, context)
84+
expect(missingVars.success).toBe(false)
85+
expect(missingVars.error).toContain('signingSecretEnvVar')
86+
})
87+
88+
it('surfaces orchestration failures (e.g. auth.test rejection or name conflict)', async () => {
89+
mocks.performCreateCredential.mockResolvedValue({
90+
success: false,
91+
error: 'Slack rejected the bot token',
92+
})
93+
94+
const result = await executeConnectSlackBot(validParams, context)
95+
96+
expect(result.success).toBe(false)
97+
expect(result.error).toContain('Slack rejected the bot token')
98+
})
99+
100+
it('requires workspace scope', async () => {
101+
const result = await executeConnectSlackBot(validParams, { userId: 'user-1' } as never)
102+
expect(result.success).toBe(false)
103+
expect(result.error).toContain('Workspace')
104+
})
105+
})

0 commit comments

Comments
 (0)