Skip to content

Commit 5e39483

Browse files
feat(search): add shared Slack app installation and commands (#7743)
* feat(search): add shared Slack app installation and commands * fix(slack): clean up unsuccessful shared app grants * improvement(slack): rename commands to query and connect * fix(slack): scope personal revocation to affected searches * fix(slack): reject stale uninstall before revoking member grants * fix(slack): process member revocations independently of bot events
1 parent a9fdff9 commit 5e39483

47 files changed

Lines changed: 1716 additions & 301 deletions

Some content is hidden

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

apps/docs/content/docs/search/slack.mdx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,23 @@ import { Image } from '@/components/ui/image'
88

99
Slack Search indexes messages and threads each connected member can access. Public and private channels are included by default; one-to-one and group DMs are opt-in. A Sim organization admin installs the organization's Slack app, then each teammate authorizes their own account for indexing.
1010

11+
## Install the official app
12+
13+
When the shared-app rollout is enabled for Sim Search, an organization admin can open **Settings → Sim Search in Slack → Install Sim Search**, select a Slack workspace, and approve the bot installation. Slack may require workspace-admin approval. Each organization connects one Slack workspace; Enterprise Grid-wide installations are not supported yet.
14+
15+
Each member then opens **Integrations → Slack → Connect**, chooses what to index, and authorizes their own Slack account. Use the same email for Slack and your verified Sim account. Public and private channels are included by default; direct messages and group DMs are opt-in. The bot installation alone does not authorize access to members' messages.
16+
17+
Slack uses the same indexing pipeline as other connected sources. Both the Sim web Assistant and Slack bot search the organization's knowledge base, applying the current person's access permissions. Newly connected content becomes searchable after indexing completes. Connection and sync status appear in Integrations.
18+
19+
- DM **Sim Search**, or mention it in a channel it has joined.
20+
- Use **/query [question]** to start a private DM thread. Channel invocations keep personalized answers and account details in DMs.
21+
- Use **/connect [provider]**, or **Home → Connect sources**, to open your personal Integrations in Sim. OAuth begins only after you click Connect there.
22+
- Follow source links to the original messages. Use Slack's Stop control to cancel the active answer and queued follow-ups.
23+
24+
Existing custom-app installations are not silently converted. To switch to the official app, remove the old Slack Search binding and source app configuration explicitly, install the official app, and have members authorize it afresh. Workflow integrations keep their existing app configuration.
25+
26+
The instructions below describe setting up a custom app when the official app is unavailable.
27+
1128
## Before you start
1229

1330
You need a Sim organization admin and permission to create and install an app in the target Slack workspace. Ask a Slack workspace admin for approval when app installation is restricted. Use the same email address for Slack and your verified Sim account.

apps/sim/.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,3 +252,8 @@ CRON_SECRET=your_cron_secret # Use `openssl rand -hex 32` to generate. Authentic
252252
# Hosted MISTRAL_API_KEY requests share capacity across key rotation. Map any additional
253253
# keys in the same organization to one group using SHA-256 fingerprints, never raw keys.
254254
# MISTRAL_OCR_QUOTA_GROUPS={"<64-character lowercase key fingerprint>":"organization-id"}
255+
256+
# Official Sim Search Slack app (optional; requires existing Search access)
257+
# Register the company app with bun scripts/register-platform-slack-app.ts <APP_ID> --search.
258+
# SLACK_SEARCH_APP_ID=
259+
# SLACK_SEARCH_SHARED_APP=false # Off-production fallback for the global slack-search-shared-app flag

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ export const GET = defineInternalJsonRoute({
2222
errorPolicy: internalOrchestrationErrorPolicy,
2323
mapInput: ({ query }) => query,
2424
useCase: listSlackSearchInstallations,
25-
present: ({ installations, bots }) => ({
25+
present: ({ installations, bots, sharedAppAvailable }) => ({
26+
sharedAppAvailable,
2627
bots,
2728
installations: installations.map((row) => ({
2829
...row,

apps/sim/app/api/webhooks/slack/route.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
11
import { createLogger } from '@sim/logger'
22
import { isRecordLike } from '@sim/utils/object'
3-
import { type NextRequest, NextResponse } from 'next/server'
3+
import { after, type NextRequest, NextResponse } from 'next/server'
44
import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate'
55
import { generateRequestId } from '@/lib/core/utils/request'
66
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
7+
import { receiveSlackSearchCommand } from '@/lib/knowledge/application/slack-search/commands'
78
import { resolveSlackAppInstallation } from '@/lib/knowledge/application/slack-search/ingress'
9+
import {
10+
revokeSlackSearchAccess,
11+
slackSearchLifecycleSchema,
12+
} from '@/lib/knowledge/application/slack-search/lifecycle'
13+
import { dispatchSlackSearchTurn } from '@/lib/knowledge/application/slack-search/outbox'
814
import { loadSlackAppConfiguration } from '@/lib/slack-search/app-configuration'
15+
import { slackSearchCommandEventId, slackSearchCommandSchema } from '@/lib/slack-search/commands'
916
import { dispatchSlackSearch } from '@/lib/slack-search/dispatcher'
1017
import { findWebhooksByRoutingKey, parseWebhookBody } from '@/lib/webhooks/processor'
1118
import { handleSlackChallenge, verifySlackRequestSignature } from '@/lib/webhooks/providers/slack'
@@ -69,6 +76,20 @@ async function handleSlackAppWebhook(request: NextRequest): Promise<NextResponse
6976
return authError
7077
}
7178

79+
const lifecycle = slackSearchLifecycleSchema.safeParse(payload)
80+
if (lifecycle.success) {
81+
await revokeSlackSearchAccess.execute({
82+
principal: {
83+
kind: 'slack_app',
84+
appId,
85+
appRevision: configuration.app.revision,
86+
receivedAt: new Date(receivedAt),
87+
},
88+
input: lifecycle.data,
89+
})
90+
return new NextResponse(null, { status: 200 })
91+
}
92+
7293
const interactionTeam = payload.team as { id?: unknown } | undefined
7394
const searchTeamId = typeof payload.team_id === 'string' ? payload.team_id : interactionTeam?.id
7495
const searchInstallation =
@@ -83,6 +104,27 @@ async function handleSlackAppWebhook(request: NextRequest): Promise<NextResponse
83104
input: { teamId: searchTeamId },
84105
})
85106
: null
107+
const command = slackSearchCommandSchema.safeParse(payload)
108+
if (command.success) {
109+
if (!searchInstallation)
110+
return NextResponse.json({
111+
response_type: 'ephemeral',
112+
text: 'An admin needs to install and enable Sim Search for this workspace.',
113+
})
114+
const { turnId, ...response } = await receiveSlackSearchCommand.execute({
115+
principal: {
116+
kind: 'slack_installation',
117+
...searchInstallation,
118+
appId,
119+
teamId: command.data.team_id,
120+
eventId: slackSearchCommandEventId(command.data),
121+
receivedAt: new Date(receivedAt),
122+
},
123+
input: command.data,
124+
})
125+
if (turnId) after(() => dispatchSlackSearchTurn(turnId))
126+
return NextResponse.json(response)
127+
}
86128
if (searchInstallation) {
87129
await Promise.all([
88130
dispatchSlackSearch({ ...searchInstallation, body, receivedAt }),

apps/sim/app/o/[organizationId]/integrations/connect-account-options.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
2424
import { useSearchSourceOverview, useSearchSources } from '@/hooks/queries/kb/connectors'
2525
import { organizationAccountsKeys } from '@/hooks/queries/organization-accounts'
26+
import { usePersonalSearchIntegrations } from '@/hooks/queries/personal-search-integrations'
2627
import { useSearchIntegrations } from '@/hooks/queries/search-integrations'
2728
import { searchSourceKeys } from '@/hooks/queries/utils/search-source-keys'
2829
import { CONNECTABLE_MEMBERSHIPS, useMemberEnrollment } from '@/hooks/use-member-enrollment'
@@ -43,6 +44,14 @@ export function ConnectAccountOptions({
4344
const sources = useSearchSources(scope, { search })
4445
const overview = useSearchSourceOverview(scope)
4546
const integrations = useSearchIntegrations(organization.id)
47+
const slackInventory = usePersonalSearchIntegrations({
48+
organizationId: organization.id,
49+
connectorType: 'slack',
50+
})
51+
const canConnectSharedSlack =
52+
slackInventory.data?.available.some(
53+
(entry) => entry.target.connectorType === 'slack' && !entry.target.connectorId
54+
) === true
4655
const availability = usePermissionConfig()
4756
const membershipQueryKeys = useMemo(
4857
() => [
@@ -90,7 +99,7 @@ export function ConnectAccountOptions({
9099
)
91100
const sourceChoices = SEARCH_CONNECTORS.filter((connector) => {
92101
if (
93-
connector.type === 'slack' ||
102+
(connector.type === 'slack' && !canConnectSharedSlack) ||
94103
!approvedTypes.has(connector.type) ||
95104
!connector.meta.name.toLowerCase().includes(search.toLowerCase()) ||
96105
(configuredTypes.has(connector.type) && connector.setupFields.length === 0)
@@ -124,7 +133,9 @@ export function ConnectAccountOptions({
124133
? overview
125134
: integrations.isError
126135
? integrations
127-
: null
136+
: slackInventory.isError
137+
? slackInventory
138+
: null
128139

129140
return (
130141
<>
@@ -148,6 +159,7 @@ export function ConnectAccountOptions({
148159
) : sources.isPending ||
149160
overview.isPending ||
150161
integrations.isPending ||
162+
slackInventory.isPending ||
151163
!availability.isIntegrationAvailabilityReady ? (
152164
<SettingsEmptyState variant='inline'>Loading sources…</SettingsEmptyState>
153165
) : visibleSources.length > 0 || sourceChoices.length > 0 || sources.hasNextPage ? (

apps/sim/app/o/[organizationId]/integrations/integrations.test.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,13 @@ const mocks = vi.hoisted(() => ({
2525
vi.mock('@/app/o/[organizationId]/integrations/slack-search-actions', () => ({
2626
SlackSearchActions: ({ token }: { token: string }) => <button type='button'>{token}</button>,
2727
}))
28+
vi.mock('@/hooks/queries/personal-search-integrations', () => ({
29+
usePersonalSearchIntegrations: () => ({
30+
data: { available: [] },
31+
isPending: false,
32+
isError: false,
33+
}),
34+
}))
2835
vi.mock('@/hooks/queries/search-integrations', () => ({
2936
useSearchIntegrations: mocks.integrations,
3037
}))

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ export function OrganizationSearchSlack() {
7373
description='Connect your workspace to ask questions in Slack.'
7474
trailing={
7575
<Chip variant='primary' onClick={() => setWizard({})}>
76-
Set up
76+
{installations.data.sharedAppAvailable ? 'Install Sim Search' : 'Set up'}
7777
</Chip>
7878
}
7979
/>

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

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,19 @@ export function SlackSearchSetupWizard({
6363
}
6464
}
6565

66+
const shared = Boolean(
67+
prepare.data?.sharedAppId && (!configuredAppId || configuredAppId === prepare.data.sharedAppId)
68+
)
69+
70+
function installShared() {
71+
oauth.mutate(
72+
{ organizationId, installationId, name, description, mode: 'shared' },
73+
{
74+
onSuccess: ({ authorizationUrl }) => window.location.assign(authorizationUrl),
75+
}
76+
)
77+
}
78+
6679
function advance() {
6780
if (step === 'manifest') {
6881
setStep('credentials')
@@ -86,6 +99,39 @@ export function SlackSearchSetupWizard({
8699
}
87100
}
88101

102+
if (shared)
103+
return (
104+
<ChipModal
105+
open
106+
dismissDisabled={busy}
107+
onOpenChange={(open) => {
108+
if (!open) onClose()
109+
}}
110+
srTitle='Install Sim Search'
111+
>
112+
<ChipModalHeader icon={SlackIcon} onClose={onClose}>
113+
Install Sim Search
114+
</ChipModalHeader>
115+
<ChipModalBody>
116+
<ChipModalField type='custom' title='Connect your Slack workspace'>
117+
<p className='text-[var(--text-secondary)] text-sm'>
118+
Ask Sim in DMs or mention it in a channel. Each member connects their own Slack
119+
account to index the channels and direct messages they choose to connect.
120+
</p>
121+
</ChipModalField>
122+
<ChipModalError>{error?.message}</ChipModalError>
123+
</ChipModalBody>
124+
<ChipModalFooter
125+
onCancel={onClose}
126+
primaryAction={{
127+
label: busy ? 'Connecting…' : 'Install Sim Search',
128+
disabled: busy,
129+
onClick: installShared,
130+
}}
131+
/>
132+
</ChipModal>
133+
)
134+
89135
return (
90136
<ChipModal
91137
open

apps/sim/lib/api/contracts/knowledge/slack.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,14 @@ export const slackSearchInstallationSchema = z.object({
99
appId: z.string().min(1).max(200),
1010
teamId: z.string().min(1).max(200),
1111
teamName: z.string().min(1).max(200),
12+
appKind: z.enum(['custom', 'shared']),
1213
enabled: z.boolean(),
1314
needsValidation: z.boolean(),
1415
lastOutcome: z.string().max(100).nullable(),
1516
lastEventAt: z.string().datetime().nullable(),
1617
})
1718
export const listSlackSearchResponseSchema = z.object({
19+
sharedAppAvailable: z.boolean(),
1820
installations: z.array(slackSearchInstallationSchema).max(100),
1921
bots: z
2022
.array(z.object({ id: z.string().min(1).max(200), displayName: z.string().max(500) }))
@@ -64,6 +66,7 @@ export const prepareSlackSearchContract = defineRouteContract({
6466
response: {
6567
mode: 'json',
6668
schema: z.object({
69+
sharedAppId: z.string().min(1).max(200).nullable(),
6770
manifest: z.string().max(20_000),
6871
existingApp: z
6972
.object({ appId: z.string().min(1).max(200), teamId: z.string().min(1).max(200) })
@@ -74,6 +77,7 @@ export const prepareSlackSearchContract = defineRouteContract({
7477
})
7578

7679
export const startSlackSearchOAuthBodySchema = prepareSlackSearchBodySchema.extend({
80+
mode: z.enum(['custom', 'shared']).default('custom'),
7781
installationId: z.string().min(1).max(200).optional(),
7882
clientId: z.string().trim().min(1).max(200).optional(),
7983
clientSecret: z.string().trim().min(1).max(500).optional(),

apps/sim/lib/core/config/env.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -547,6 +547,8 @@ export const env = createEnv({
547547
DROPBOX_CLIENT_ID: z.string().optional(), // Dropbox OAuth client ID
548548
DROPBOX_CLIENT_SECRET: z.string().optional(), // Dropbox OAuth client secret
549549
SLACK_CLIENT_ID: z.string().optional(), // Slack OAuth client ID
550+
SLACK_SEARCH_APP_ID: z.string().optional(),
551+
SLACK_SEARCH_SHARED_APP: z.boolean().optional(),
550552
SLACK_CLIENT_SECRET: z.string().optional(), // Slack OAuth client secret
551553
SLACK_SIGNING_SECRET: z.string().optional(), // Official Sim Slack app signing secret (verifies inbound events for the native OAuth trigger)
552554
SLACK_EXTENDED_SCOPES: z.boolean().optional(), // Request app_mentions:read, assistant:write, im:history — only where the Slack app is approved for them

0 commit comments

Comments
 (0)