Skip to content

Commit 26b2ac0

Browse files
feat(slack): switch custom bots to the Sim Search app
1 parent 9eee8e4 commit 26b2ac0

13 files changed

Lines changed: 573 additions & 118 deletions

File tree

apps/sim/app/api/knowledge/slack/setup/route.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ vi.mock('@/lib/knowledge/application/slack-search/setup', async () => {
1717
}
1818
})
1919

20-
import { createSlackSearchManifest } from '@/lib/slack-search/manifest'
20+
import { OrchestrationError } from '@/lib/core/orchestration/types'
2121
import { POST as start } from '@/app/api/knowledge/slack/oauth/route'
2222
import { POST as prepare } from '@/app/api/knowledge/slack/setup/route'
2323

@@ -35,13 +35,13 @@ describe.each([
3535
['prepare', prepare, mocks.prepare],
3636
['OAuth', start, mocks.start],
3737
] as const)('Slack %s route errors', (_name, route, execute) => {
38-
it('returns an actionable 400 for a non-HTTPS app URL', async () => {
39-
execute.mockImplementation(() =>
40-
createSlackSearchManifest(input.name, input.description, 'http://localhost:3000')
38+
it('returns application validation errors', async () => {
39+
execute.mockRejectedValue(
40+
new OrchestrationError('validation', 'Slack app credentials are required')
4141
)
4242
const response = await route(createMockRequest('POST', input))
4343
expect(response.status).toBe(400)
44-
expect(await response.json()).toMatchObject({ error: expect.stringContaining('public HTTPS') })
44+
expect(await response.json()).toMatchObject({ error: 'Slack app credentials are required' })
4545
expect(execute).toHaveBeenCalledOnce()
4646
})
4747

apps/sim/app/o/[organizationId]/settings/components/organization-search-slack.test.tsx

Lines changed: 79 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -106,8 +106,8 @@ function button(label: string) {
106106
async function click(label: string) {
107107
await act(async () => button(label).click())
108108
}
109-
async function action(label: string) {
110-
const trigger = container.querySelector<HTMLButtonElement>('[aria-label="Sim Search actions"]')!
109+
async function action(label: string, name = 'Sim Search (custom bot)') {
110+
const trigger = container.querySelector<HTMLButtonElement>(`[aria-label="${name} actions"]`)!
111111
await act(async () => {
112112
trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
113113
})
@@ -137,27 +137,33 @@ describe('Slack Search settings and shared wizard', () => {
137137
},
138138
})
139139
await render()
140-
await click('Install Sim Search')
141-
expect(document.querySelector('[role="dialog"]')).toHaveTextContent('Install Sim Search')
140+
if (installations.length) {
141+
expect(container).toHaveTextContent('Reconnect required')
142+
expect(container).toHaveTextContent('Sim Search (custom bot)')
143+
await action('Install Sim Search')
144+
} else {
145+
await click('Install Sim Search')
146+
}
147+
expect(document.querySelector('[role="dialog"]')).toHaveTextContent(
148+
'Install the Sim Search app'
149+
)
142150
expect(document.querySelectorAll('input')).toHaveLength(0)
143151
expect(mocks.install).not.toHaveBeenCalled()
144-
await click('Install Sim Search')
152+
await click('Continue with Slack')
145153
expect(mocks.install).toHaveBeenCalledExactlyOnceWith(
146154
{
147155
organizationId: 'org-1',
148-
installationId: undefined,
156+
installationId: installations[0]?.id,
149157
name: 'Sim Search',
150158
description: expect.any(String),
151159
mode: 'shared',
152160
},
153161
expect.any(Object)
154162
)
155-
mocks.installError = new Error(
156-
'Remove the previous Slack source configuration before switching apps; members must reconnect'
157-
)
163+
mocks.installError = new Error('Slack authorization failed. Try again.')
158164
await render()
159165
expect(document.querySelector('[role="dialog"] [role="alert"]')).toHaveTextContent(
160-
'Remove the previous Slack source configuration'
166+
'Slack authorization failed'
161167
)
162168
expect(mocks.configure).not.toHaveBeenCalled()
163169
expect(mocks.remove).not.toHaveBeenCalled()
@@ -175,8 +181,8 @@ describe('Slack Search settings and shared wizard', () => {
175181
mocks.manifest.mockReturnValue({ data: { sharedAppId: 'A_SHARED', existingApp: null } })
176182
await render()
177183
expect(container).not.toHaveTextContent('Install Sim Search')
178-
await action('Reconnect')
179-
await click('Install Sim Search')
184+
await action('Reconnect', 'Sim Search')
185+
await click('Continue with Slack')
180186
expect(mocks.install).toHaveBeenCalledWith(
181187
expect.objectContaining({ mode: 'shared', installationId: installation.id }),
182188
expect.any(Object)
@@ -192,12 +198,12 @@ describe('Slack Search settings and shared wizard', () => {
192198
refetch: mocks.refetch,
193199
})
194200
await render()
195-
await click('Install Sim Search')
201+
await action('Install Sim Search')
196202
expect(document.querySelector('[role="dialog"] [role="alert"]')).toHaveTextContent(
197203
'Sim Search installation is unavailable'
198204
)
199205
expect(document.querySelector('[role="dialog"]')).not.toHaveTextContent('Create Slack app')
200-
expect(button('Install Sim Search')).toBeDisabled()
206+
expect(button('Continue with Slack')).toBeDisabled()
201207
expect(mocks.install).not.toHaveBeenCalled()
202208
await click('Retry')
203209
expect(mocks.refetch).toHaveBeenCalledOnce()
@@ -213,11 +219,11 @@ describe('Slack Search settings and shared wizard', () => {
213219
refetch: mocks.refetch,
214220
})
215221
await render()
216-
await click('Install Sim Search')
222+
await action('Install Sim Search')
217223
expect(document.querySelector('[role="dialog"] [role="alert"]')).toHaveTextContent(
218224
'Could not load Slack setup'
219225
)
220-
expect(button('Install Sim Search')).toBeDisabled()
226+
expect(button('Continue with Slack')).toBeDisabled()
221227
await click('Retry')
222228
expect(mocks.refetch).toHaveBeenCalledOnce()
223229
expect(mocks.install).not.toHaveBeenCalled()
@@ -229,6 +235,58 @@ describe('Slack Search settings and shared wizard', () => {
229235
expect(container).toHaveTextContent('Open in Slack')
230236
})
231237

238+
it('prompts the existing custom bot to reconnect when the feature becomes available', async () => {
239+
await render(true)
240+
expect(container).toHaveTextContent('Sim Search (custom bot)')
241+
expect(container).toHaveTextContent('Enabled')
242+
expect(container).not.toHaveTextContent('Reconnect required')
243+
mocks.list.mockReturnValue({
244+
data: { sharedAppAvailable: true, installations: [installation], bots: [] },
245+
})
246+
mocks.manifest.mockReturnValue({ data: { sharedAppId: 'A_SHARED', existingApp: null } })
247+
await render()
248+
expect(container).toHaveTextContent('Reconnect required')
249+
expect(container).not.toHaveTextContent('Install Sim Search')
250+
expect(mocks.install).not.toHaveBeenCalled()
251+
expect(mocks.configure).not.toHaveBeenCalled()
252+
await action('Install Sim Search')
253+
expect(document.querySelector('[role="dialog"]')).toHaveTextContent(
254+
'Install the Sim Search app'
255+
)
256+
expect(button('Continue with Slack')).not.toBeDisabled()
257+
await click('Cancel')
258+
expect(mocks.install).not.toHaveBeenCalled()
259+
expect(mocks.remove).not.toHaveBeenCalled()
260+
})
261+
262+
it('shows the native app alongside the retained custom bot after installing', async () => {
263+
mocks.list.mockReturnValue({
264+
data: {
265+
sharedAppAvailable: true,
266+
installations: [
267+
{ ...installation, enabled: false },
268+
{
269+
...installation,
270+
id: 'native-installation',
271+
credentialId: 'native-credential',
272+
appId: 'A_SHARED',
273+
appKind: 'shared',
274+
},
275+
],
276+
bots: [],
277+
},
278+
})
279+
await render()
280+
expect(container.querySelector('[aria-label="Sim Search (custom bot) actions"]')).not.toBeNull()
281+
expect(container.querySelector('[aria-label="Sim Search actions"]')).not.toBeNull()
282+
expect(container).toHaveTextContent('Disabled')
283+
expect(container).toHaveTextContent('Enabled')
284+
expect(container).not.toHaveTextContent('Reconnect required')
285+
expect(container).not.toHaveTextContent('Install Sim Search')
286+
expect(container.querySelectorAll('a[href*="slack.com/app_redirect"]')).toHaveLength(2)
287+
expect(mocks.install).not.toHaveBeenCalled()
288+
})
289+
232290
it('starts with one setup action and a Slack app link, with no manifest preview or form', async () => {
233291
await render()
234292
expect(container.querySelectorAll('button')).toHaveLength(1)
@@ -246,13 +304,15 @@ describe('Slack Search settings and shared wizard', () => {
246304

247305
it('shows setup errors and blocks progression until the manifest loads', async () => {
248306
mocks.manifest.mockReturnValue({
249-
error: new Error('Slack needs a public HTTPS URL to send messages to Sim.'),
307+
error: new Error('Slack app configuration is unavailable.'),
250308
refetch: mocks.refetch,
251309
isPending: false,
252310
})
253311
await render()
254312
await click('Set up')
255-
expect(document.querySelector('[role="alert"]')).toHaveTextContent('public HTTPS')
313+
expect(document.querySelector('[role="alert"]')).toHaveTextContent(
314+
'Slack app configuration is unavailable.'
315+
)
256316
expect(document.querySelector('[role="dialog"]')).not.toHaveTextContent('Step 1')
257317
expect(document.querySelector('[role="dialog"]')).not.toHaveTextContent('Continue')
258318
await click('Retry')
@@ -294,7 +354,7 @@ describe('Slack Search settings and shared wizard', () => {
294354
expect(document.querySelector('[role="dialog"]')).not.toHaveTextContent('Loading Slack setup')
295355
if (mode === 'shared') {
296356
expect(document.querySelector('[role="dialog"]')).not.toHaveTextContent('Step 1')
297-
await click('Install Sim Search')
357+
await click('Continue with Slack')
298358
expect(mocks.install).toHaveBeenCalledWith(
299359
expect.objectContaining({ organizationId: 'org-1', mode: 'shared' }),
300360
expect.any(Object)

apps/sim/app/o/[organizationId]/settings/components/organization-search-slack.tsx

Lines changed: 28 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,12 @@ export function OrganizationSearchSlack() {
4444
if (!viewer.isAdmin) return null
4545
const busy = configure.isPending || remove.isPending
4646
const bots = installations.data?.bots ?? []
47-
const canInstallSharedApp =
48-
!installations.error &&
49-
installations.data?.sharedAppAvailable &&
50-
!installations.data.installations.some((installation) => installation.appKind === 'shared')
47+
const canInstallSharedApp = !installations.error && installations.data?.sharedAppAvailable
48+
const sharedTeams = new Set(
49+
installations.data?.installations
50+
.filter((installation) => installation.appKind === 'shared')
51+
.map((installation) => installation.teamId)
52+
)
5153

5254
return (
5355
<SettingsPanel>
@@ -58,16 +60,7 @@ export function OrganizationSearchSlack() {
5860
<Chip onClick={() => void setSetupResult(null)}>Dismiss</Chip>
5961
</div>
6062
)}
61-
<SettingsSection
62-
label='Connection'
63-
action={
64-
canInstallSharedApp && Boolean(installations.data?.installations.length) ? (
65-
<Chip variant='primary' disabled={busy} onClick={() => setWizard({ mode: 'shared' })}>
66-
Install Sim Search
67-
</Chip>
68-
) : undefined
69-
}
70-
>
63+
<SettingsSection label='Connection'>
7164
{installations.error ? (
7265
<SettingsQueryErrorState
7366
error={installations.error}
@@ -96,16 +89,19 @@ export function OrganizationSearchSlack() {
9689
/>
9790
) : (
9891
installations.data.installations.map((installation) => {
99-
const name =
100-
bots.find((bot) => bot.id === installation.credentialId)?.displayName ??
101-
installation.teamName
102-
const connectionError = installation.needsValidation
103-
? 'Reconnect to verify the app’s credentials and permissions.'
104-
: ['delivery_failed', 'assistant_or_delivery_failed'].includes(
105-
installation.lastOutcome ?? ''
106-
)
107-
? 'The last reply failed. Check the Slack connection.'
108-
: null
92+
const custom = installation.appKind === 'custom'
93+
const name = custom ? 'Sim Search (custom bot)' : 'Sim Search'
94+
const needsInstall =
95+
canInstallSharedApp && custom && !sharedTeams.has(installation.teamId)
96+
const connectionError = needsInstall
97+
? null
98+
: installation.needsValidation
99+
? 'Reconnect to verify the app’s credentials and permissions.'
100+
: ['delivery_failed', 'assistant_or_delivery_failed'].includes(
101+
installation.lastOutcome ?? ''
102+
)
103+
? 'The last reply failed. Check the Slack connection.'
104+
: null
109105
return (
110106
<SettingsResourceRow
111107
key={installation.id}
@@ -120,7 +116,7 @@ export function OrganizationSearchSlack() {
120116
}
121117
badge={
122118
<ChipTag>
123-
{installation.needsValidation
119+
{needsInstall || installation.needsValidation
124120
? 'Reconnect required'
125121
: installation.enabled
126122
? 'Enabled'
@@ -140,14 +136,18 @@ export function OrganizationSearchSlack() {
140136
label={`${name} actions`}
141137
actions={[
142138
{
143-
label: 'Reconnect',
139+
label: needsInstall ? 'Install Sim Search' : 'Reconnect',
144140
disabled: busy,
145141
onSelect: () =>
146142
setWizard({
147-
mode: installation.appKind,
143+
mode: needsInstall ? 'shared' : installation.appKind,
148144
installationId: installation.id,
149145
appId: installation.appId,
150-
initialName: name,
146+
initialName:
147+
custom && !needsInstall
148+
? bots.find((bot) => bot.id === installation.credentialId)
149+
?.displayName
150+
: undefined,
151151
}),
152152
},
153153
{

apps/sim/components/integrations/slack-search-setup-wizard.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -149,14 +149,16 @@ export function SlackSearchSetupWizard({
149149
onOpenChange={(open) => {
150150
if (!open) onClose()
151151
}}
152-
srTitle='Install Sim Search'
152+
srTitle='Install the Sim Search app'
153+
size='sm'
153154
>
154155
<ChipModalHeader icon={SlackIcon} onClose={onClose}>
155-
Install Sim Search
156+
Install the Sim Search app
156157
</ChipModalHeader>
157158
<ChipModalBody>
158159
<p className='px-2 text-[var(--text-secondary)] text-sm'>
159-
Choose your Slack workspace and approve Sim Search.
160+
Add Sim Search to your Slack workspace to ask questions and get answers from your
161+
connected sources.
160162
</p>
161163
<ChipModalError>
162164
{error?.message ??
@@ -179,7 +181,7 @@ export function SlackSearchSetupWizard({
179181
: undefined
180182
}
181183
primaryAction={{
182-
label: busy ? 'Connecting…' : 'Install Sim Search',
184+
label: busy ? 'Connecting…' : 'Continue with Slack',
183185
disabled: busy || !prepare.data.sharedAppId || Boolean(prepare.error),
184186
onClick: installShared,
185187
}}

apps/sim/lib/credential-groups/provider-configuration.test.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const shared = vi.hoisted(() => ({
1212
flag: vi.fn(),
1313
}))
1414
vi.mock('@/lib/core/config/env', () => ({ env: shared.env }))
15+
vi.mock('@/lib/core/config/env-flags', () => ({ isHosted: true }))
1516
vi.mock('@/lib/core/config/feature-flags', () => ({ isFeatureEnabled: shared.flag }))
1617

1718
vi.mock('@/lib/core/security/encryption', () => ({
@@ -39,7 +40,7 @@ const configuration = {
3940
beforeEach(() => {
4041
resetDbChainMock()
4142
shared.env.SLACK_SEARCH_APP_ID = ''
42-
shared.flag.mockResolvedValue(true)
43+
shared.flag.mockReset().mockResolvedValue(true)
4344
})
4445

4546
describe('organization Slack app references', () => {
@@ -105,6 +106,39 @@ describe('organization Slack app references', () => {
105106
else await expect(result).rejects.toThrow('disabled or removed')
106107
}
107108
)
109+
it('keeps using the custom app for personal sources after a different native app is installed', async () => {
110+
shared.env.SLACK_SEARCH_APP_ID = 'ANATIVE'
111+
dbChainMockFns.limit
112+
.mockResolvedValueOnce([
113+
{
114+
encryptedProviderConfiguration:
115+
await encryptCredentialGroupProviderConfiguration(configuration),
116+
},
117+
])
118+
.mockResolvedValueOnce([
119+
{
120+
id: 'A1',
121+
kind: 'custom',
122+
organizationId: 'org-1',
123+
clientId: 'custom-client',
124+
encryptedClientSecret: 'encrypted:custom-secret',
125+
encryptedSigningSecret: 'encrypted:custom-signing',
126+
},
127+
])
128+
await expect(
129+
getSlackCredentialGroupConfiguration({
130+
organizationId: 'org-1',
131+
credentialGroupId: 'group-1',
132+
})
133+
).resolves.toMatchObject({
134+
appId: 'A1',
135+
teamId: 'T1',
136+
clientId: 'custom-client',
137+
clientSecret: 'custom-secret',
138+
})
139+
expect(dbChainMockFns.limit).toHaveBeenCalledTimes(2)
140+
expect(shared.flag).not.toHaveBeenCalled()
141+
})
108142
it('fails when the referenced app is absent from the owning organization', async () => {
109143
dbChainMockFns.limit
110144
.mockResolvedValueOnce([

0 commit comments

Comments
 (0)