Skip to content

Commit c8b632f

Browse files
feat: serve organization search through Slack custom bots
1 parent 305b5ef commit c8b632f

66 files changed

Lines changed: 2845 additions & 46 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/sim/app/api/files/uploads/purposes.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,8 @@ function requireSessionScope(value: string | null, label = 'scope'): string {
235235

236236
async function principalUserId(principal: Principal, workspaceId?: string): Promise<string> {
237237
switch (principal.kind) {
238+
case 'slack_installation':
239+
throw new UploadSessionError('forbidden', 'Slack installations cannot create uploads')
238240
case 'session':
239241
case 'personal_api_key':
240242
case 'oauth_access_token':
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { removeSlackSearchContract } from '@/lib/api/contracts/knowledge/slack'
2+
import {
3+
defineInternalJsonRoute,
4+
internalOrchestrationErrorPolicy,
5+
internalRateLimits,
6+
internalSessionAuth,
7+
} from '@/lib/api/server/routes'
8+
import { knowledgeOperations } from '@/lib/knowledge/application/operations'
9+
import { removeSlackSearchInstallation } from '@/lib/knowledge/application/slack-search/installations'
10+
11+
export const DELETE = defineInternalJsonRoute({
12+
contract: removeSlackSearchContract,
13+
auth: internalSessionAuth,
14+
operation: knowledgeOperations.removeSlackInstallation,
15+
rateLimit: internalRateLimits.user({ bucketName: 'slack-search-settings' }),
16+
errorPolicy: internalOrchestrationErrorPolicy,
17+
mapInput: ({ query, params }) => ({ ...query, ...params }),
18+
useCase: removeSlackSearchInstallation,
19+
present: (result) => result,
20+
})
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import {
2+
configureSlackSearchContract,
3+
listSlackSearchContract,
4+
} from '@/lib/api/contracts/knowledge/slack'
5+
import {
6+
defineInternalJsonRoute,
7+
internalOrchestrationErrorPolicy,
8+
internalRateLimits,
9+
internalSessionAuth,
10+
} from '@/lib/api/server/routes'
11+
import { knowledgeOperations } from '@/lib/knowledge/application/operations'
12+
import {
13+
configureSlackSearchInstallation,
14+
listSlackSearchInstallations,
15+
} from '@/lib/knowledge/application/slack-search/installations'
16+
17+
export const GET = defineInternalJsonRoute({
18+
contract: listSlackSearchContract,
19+
auth: internalSessionAuth,
20+
operation: knowledgeOperations.listSlackInstallations,
21+
rateLimit: internalRateLimits.user({ bucketName: 'slack-search-settings' }),
22+
errorPolicy: internalOrchestrationErrorPolicy,
23+
mapInput: ({ query }) => query,
24+
useCase: listSlackSearchInstallations,
25+
present: ({ installations, bots }) => ({
26+
bots,
27+
installations: installations.map((row) => ({
28+
...row,
29+
lastEventAt: row.lastEventAt?.toISOString() ?? null,
30+
})),
31+
}),
32+
})
33+
34+
export const POST = defineInternalJsonRoute({
35+
contract: configureSlackSearchContract,
36+
auth: internalSessionAuth,
37+
operation: knowledgeOperations.configureSlackInstallation,
38+
rateLimit: internalRateLimits.user({ bucketName: 'slack-search-settings' }),
39+
errorPolicy: internalOrchestrationErrorPolicy,
40+
mapInput: ({ body }) => body,
41+
useCase: configureSlackSearchInstallation,
42+
present: (result) => result,
43+
})

apps/sim/app/api/webhooks/slack/custom/[credentialId]/route.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,19 @@ const {
1010
mockGetSlackBotCredential,
1111
mockHandleChallenge,
1212
mockVerifySignature,
13+
mockDispatchSearch,
1314
} = vi.hoisted(() => ({
1415
mockParseWebhookBody: vi.fn(),
1516
mockFindWebhooksByRoutingKey: vi.fn(),
1617
mockDispatchResolvedWebhookTarget: vi.fn(),
1718
mockGetSlackBotCredential: vi.fn(),
1819
mockHandleChallenge: vi.fn(),
1920
mockVerifySignature: vi.fn(),
21+
mockDispatchSearch: vi.fn(),
2022
}))
2123

24+
vi.mock('@/lib/slack-search/dispatcher', () => ({ dispatchSlackSearch: mockDispatchSearch }))
25+
2226
vi.mock('@/lib/core/admission/gate', () => ({
2327
tryAdmit: () => ({ release: vi.fn() }),
2428
admissionRejectedResponse: () => new Response(null, { status: 503 }),
@@ -66,6 +70,7 @@ function webhook(id: string) {
6670
describe('Slack custom-bot webhook route', () => {
6771
beforeEach(() => {
6872
vi.clearAllMocks()
73+
mockDispatchSearch.mockResolvedValue(undefined)
6974
mockHandleChallenge.mockReturnValue(null)
7075
mockVerifySignature.mockReturnValue(null)
7176
mockParseWebhookBody.mockResolvedValue({
@@ -74,6 +79,7 @@ describe('Slack custom-bot webhook route', () => {
7479
})
7580
mockGetSlackBotCredential.mockResolvedValue({
7681
signingSecret: 'sec',
82+
credentialVersion: 'version',
7783
botToken: 'xoxb-x',
7884
teamId: 'T1',
7985
})
@@ -134,6 +140,20 @@ describe('Slack custom-bot webhook route', () => {
134140
)
135141
expect(mockDispatchResolvedWebhookTarget).toHaveBeenCalledTimes(1)
136142
expect(res.status).toBe(200)
143+
expect(mockDispatchSearch).toHaveBeenCalledWith(
144+
expect.objectContaining({
145+
credentialId: CREDENTIAL_ID,
146+
credentialVersion: 'version',
147+
body: messageBody,
148+
})
149+
)
150+
})
151+
152+
it('returns a retryable failure when Search enqueue fails beside a successful workflow', async () => {
153+
mockDispatchSearch.mockRejectedValue(new Error('Queue unavailable'))
154+
const response = await POST(makeRequest(), context)
155+
expect(response.status).toBeGreaterThanOrEqual(500)
156+
expect(mockDispatchResolvedWebhookTarget).toHaveBeenCalledOnce()
137157
})
138158

139159
it('still returns 200 when the dispatcher filters the event', async () => {

apps/sim/app/api/webhooks/slack/custom/[credentialId]/route.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@ import { type NextRequest, NextResponse } from 'next/server'
22
import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate'
33
import { generateRequestId } from '@/lib/core/utils/request'
44
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
5+
import { dispatchSlackSearch } from '@/lib/slack-search/dispatcher'
56
import { parseWebhookBody } from '@/lib/webhooks/processor'
67
import { handleSlackChallenge } from '@/lib/webhooks/providers/slack'
78
import {
9+
authenticateSlackCustomBotRequest,
810
dispatchSlackCustomBotCredential,
911
handleSlackAgentSessionStopped,
10-
verifySlackCustomBotCredentialRequest,
1112
} from '@/lib/webhooks/slack-custom-ingress'
1213
import { getSlackDispatchResponse } from '@/lib/webhooks/slack-dispatch'
1314

@@ -59,14 +60,14 @@ async function handleSlackCustomBotWebhook(
5960
return challenge
6061
}
6162

62-
const authError = await verifySlackCustomBotCredentialRequest({
63+
const authentication = await authenticateSlackCustomBotRequest({
6364
credentialId,
6465
request,
6566
rawBody,
6667
requestId,
6768
})
68-
if (authError) {
69-
return authError
69+
if (authentication instanceof Response) {
70+
return authentication
7071
}
7172

7273
const [, dispatchResults] = await Promise.all([
@@ -78,6 +79,12 @@ async function handleSlackCustomBotWebhook(
7879
requestId,
7980
receivedAt,
8081
}),
82+
dispatchSlackSearch({
83+
credentialId,
84+
credentialVersion: authentication.credentialVersion,
85+
body,
86+
receivedAt,
87+
}),
8188
])
8289
return getSlackDispatchResponse(dispatchResults)
8390
}

apps/sim/app/o/[organizationId]/settings/[section]/settings.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ const OrganizationConnectedAccounts = dynamic(() =>
2424
(m) => m.OrganizationConnectedAccounts
2525
)
2626
)
27+
const OrganizationSearchSlack = dynamic(() =>
28+
import('@/app/o/[organizationId]/settings/components/organization-search-slack').then((m) => m.OrganizationSearchSlack)
29+
)
2730

2831
const TeamManagement = dynamic(() =>
2932
import('@/app/workspace/[workspaceId]/settings/components/team-management/team-management').then(
@@ -78,6 +81,7 @@ export function OrganizationSettings({ section }: OrganizationSettingsProps) {
7881
<OrganizationConnectedAccounts organizationId={organizationId} />
7982
)}
8083
{section === 'search-mcp' && <OrganizationSearchMcp />}
84+
{section === 'search-slack' && <OrganizationSearchSlack />}
8185
{section === 'members' && (
8286
<TeamManagement
8387
organizationId={organizationId}
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
'use client'
2+
3+
import { useState } from 'react'
4+
import { Chip, ChipDropdown, ChipSwitch } from '@sim/emcn'
5+
import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
6+
import { ConnectSlackBotModal } from '@/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal'
7+
import { SettingsQueryErrorState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
8+
import {
9+
useConfigureSlackSearch,
10+
useRemoveSlackSearch,
11+
useSlackSearchInstallations,
12+
} from '@/hooks/queries/slack-search'
13+
14+
/** Organization admins bind an existing custom bot or create one using the DM Search manifest. */
15+
export function OrganizationSearchSlack() {
16+
const { organization, viewer } = useOrganizationContext()
17+
const installations = useSlackSearchInstallations(viewer.isAdmin ? organization.id : undefined)
18+
const configure = useConfigureSlackSearch()
19+
const remove = useRemoveSlackSearch()
20+
const [selectedBot, setSelectedBot] = useState('')
21+
const [modal, setModal] = useState<{ credentialId?: string; displayName?: string } | null>(null)
22+
if (!viewer.isAdmin) return null
23+
const busy = configure.isPending || remove.isPending
24+
const error = configure.error ?? remove.error
25+
const bots = installations.data?.bots ?? []
26+
const availableBots = bots.filter(
27+
(bot) =>
28+
!installations.data?.installations.some(
29+
(installation) => installation.credentialId === bot.id
30+
)
31+
)
32+
33+
return (
34+
<div className='flex max-w-2xl flex-col gap-6'>
35+
<p className='text-[var(--text-secondary)] text-sm'>
36+
Members can DM the bot to find documents they can access in Sim Search. Their Slack email
37+
must match their verified Sim account in this organization.
38+
</p>
39+
{installations.error ? (
40+
<SettingsQueryErrorState
41+
error={installations.error}
42+
fallback='Could not load Slack bots'
43+
isRetrying={installations.isFetching}
44+
onRetry={() => void installations.refetch()}
45+
variant='inline'
46+
/>
47+
) : !installations.data ? (
48+
<p className='text-[var(--text-muted)] text-sm'>Loading Slack bots…</p>
49+
) : (
50+
<>
51+
{installations.data.installations.map((installation) => (
52+
<div
53+
key={installation.id}
54+
className='flex flex-col gap-3 border-[var(--border-1)] border-b pb-4'
55+
>
56+
<div className='flex items-center justify-between gap-4'>
57+
<div className='min-w-0'>
58+
<p className='truncate text-[var(--text-primary)] text-sm'>
59+
{bots.find((bot) => bot.id === installation.credentialId)?.displayName ??
60+
installation.teamName}
61+
</p>
62+
<p className='text-[var(--text-muted)] text-caption'>{installation.teamName}</p>
63+
</div>
64+
<fieldset disabled={busy}>
65+
<ChipSwitch
66+
aria-label={`Search in ${installation.teamName}`}
67+
value={installation.enabled ? 'enabled' : 'disabled'}
68+
options={[
69+
{ value: 'disabled', label: 'Off' },
70+
{ value: 'enabled', label: 'On' },
71+
]}
72+
onChange={(value) =>
73+
configure.mutate({
74+
organizationId: organization.id,
75+
credentialId: installation.credentialId,
76+
enabled: value === 'enabled',
77+
})
78+
}
79+
/>
80+
</fieldset>
81+
</div>
82+
{installation.needsValidation && (
83+
<p className='text-[var(--text-error)] text-caption'>
84+
The bot credential changed. Validate it to resume Search.
85+
</p>
86+
)}
87+
{installation.lastOutcome === 'delivery_failed' && (
88+
<p className='text-[var(--text-error)] text-caption'>
89+
The last reply could not be delivered. Check the Slack connection.
90+
</p>
91+
)}
92+
<div className='flex flex-wrap gap-2'>
93+
{installation.needsValidation && (
94+
<Chip
95+
disabled={busy}
96+
onClick={() =>
97+
configure.mutate({
98+
organizationId: organization.id,
99+
credentialId: installation.credentialId,
100+
enabled: true,
101+
})
102+
}
103+
>
104+
Validate and enable
105+
</Chip>
106+
)}
107+
<Chip
108+
disabled={busy}
109+
onClick={() =>
110+
setModal({
111+
credentialId: installation.credentialId,
112+
displayName: bots.find((bot) => bot.id === installation.credentialId)
113+
?.displayName,
114+
})
115+
}
116+
>
117+
Reconnect
118+
</Chip>
119+
<Chip
120+
disabled={busy}
121+
onClick={() =>
122+
remove.mutate({
123+
organizationId: organization.id,
124+
installationId: installation.id,
125+
})
126+
}
127+
>
128+
Remove from Search
129+
</Chip>
130+
</div>
131+
</div>
132+
))}
133+
{availableBots.length > 0 && (
134+
<div className='flex flex-wrap items-center gap-2'>
135+
<ChipDropdown
136+
value={selectedBot}
137+
onChange={setSelectedBot}
138+
options={availableBots.map((bot) => ({ value: bot.id, label: bot.displayName }))}
139+
placeholder='Choose a connected bot'
140+
/>
141+
<Chip
142+
disabled={!selectedBot || busy}
143+
onClick={() =>
144+
configure.mutate({
145+
organizationId: organization.id,
146+
credentialId: selectedBot,
147+
enabled: true,
148+
})
149+
}
150+
>
151+
Connect to Search
152+
</Chip>
153+
<Chip
154+
disabled={!selectedBot || busy}
155+
onClick={() =>
156+
setModal({
157+
credentialId: selectedBot,
158+
displayName: bots.find((bot) => bot.id === selectedBot)?.displayName,
159+
})
160+
}
161+
>
162+
Update bot permissions
163+
</Chip>
164+
</div>
165+
)}
166+
<div>
167+
<Chip disabled={busy} onClick={() => setModal({})}>
168+
Create a custom Slack bot
169+
</Chip>
170+
</div>
171+
</>
172+
)}
173+
{error && (
174+
<p role='alert' className='text-[var(--text-error)] text-sm'>
175+
{error.message}
176+
</p>
177+
)}
178+
{configure.isPending && (
179+
<p role='status' className='text-[var(--text-muted)] text-caption'>
180+
Validating the Slack connection…
181+
</p>
182+
)}
183+
{modal && (
184+
<ConnectSlackBotModal
185+
key={modal.credentialId ?? 'new'}
186+
open
187+
purpose='search'
188+
organizationId={organization.id}
189+
credentialId={modal.credentialId}
190+
initialDisplayName={modal.displayName}
191+
onOpenChange={(open) => {
192+
if (!open) setModal(null)
193+
}}
194+
onCreated={(credentialId) => {
195+
setModal(null)
196+
configure.mutate({ organizationId: organization.id, credentialId, enabled: true })
197+
}}
198+
/>
199+
)}
200+
</div>
201+
)
202+
}

0 commit comments

Comments
 (0)