Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions apps/docs/content/docs/search/slack.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,23 @@ import { Image } from '@/components/ui/image'

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.

## Install the official app

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.

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.

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.

- DM **Sim Search**, or mention it in a channel it has joined.
- Use **/query [question]** to start a private DM thread. Channel invocations keep personalized answers and account details in DMs.
- Use **/connect [provider]**, or **Home → Connect sources**, to open your personal Integrations in Sim. OAuth begins only after you click Connect there.
- Follow source links to the original messages. Use Slack's Stop control to cancel the active answer and queued follow-ups.

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.

The instructions below describe setting up a custom app when the official app is unavailable.

## Before you start

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.
Expand Down
5 changes: 5 additions & 0 deletions apps/sim/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -252,3 +252,8 @@ CRON_SECRET=your_cron_secret # Use `openssl rand -hex 32` to generate. Authentic
# Hosted MISTRAL_API_KEY requests share capacity across key rotation. Map any additional
# keys in the same organization to one group using SHA-256 fingerprints, never raw keys.
# MISTRAL_OCR_QUOTA_GROUPS={"<64-character lowercase key fingerprint>":"organization-id"}

# Official Sim Search Slack app (optional; requires existing Search access)
# Register the company app with bun scripts/register-platform-slack-app.ts <APP_ID> --search.
# SLACK_SEARCH_APP_ID=
# SLACK_SEARCH_SHARED_APP=false # Off-production fallback for the global slack-search-shared-app flag
3 changes: 2 additions & 1 deletion apps/sim/app/api/knowledge/slack/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ export const GET = defineInternalJsonRoute({
errorPolicy: internalOrchestrationErrorPolicy,
mapInput: ({ query }) => query,
useCase: listSlackSearchInstallations,
present: ({ installations, bots }) => ({
present: ({ installations, bots, sharedAppAvailable }) => ({
sharedAppAvailable,
bots,
installations: installations.map((row) => ({
...row,
Expand Down
44 changes: 43 additions & 1 deletion apps/sim/app/api/webhooks/slack/route.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
import { createLogger } from '@sim/logger'
import { isRecordLike } from '@sim/utils/object'
import { type NextRequest, NextResponse } from 'next/server'
import { after, type NextRequest, NextResponse } from 'next/server'
import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { receiveSlackSearchCommand } from '@/lib/knowledge/application/slack-search/commands'
import { resolveSlackAppInstallation } from '@/lib/knowledge/application/slack-search/ingress'
import {
revokeSlackSearchAccess,
slackSearchLifecycleSchema,
} from '@/lib/knowledge/application/slack-search/lifecycle'
import { dispatchSlackSearchTurn } from '@/lib/knowledge/application/slack-search/outbox'
import { loadSlackAppConfiguration } from '@/lib/slack-search/app-configuration'
import { slackSearchCommandEventId, slackSearchCommandSchema } from '@/lib/slack-search/commands'
import { dispatchSlackSearch } from '@/lib/slack-search/dispatcher'
import { findWebhooksByRoutingKey, parseWebhookBody } from '@/lib/webhooks/processor'
import { handleSlackChallenge, verifySlackRequestSignature } from '@/lib/webhooks/providers/slack'
Expand Down Expand Up @@ -69,6 +76,20 @@ async function handleSlackAppWebhook(request: NextRequest): Promise<NextResponse
return authError
}

const lifecycle = slackSearchLifecycleSchema.safeParse(payload)
if (lifecycle.success) {
await revokeSlackSearchAccess.execute({
principal: {
kind: 'slack_app',
appId,
appRevision: configuration.app.revision,
receivedAt: new Date(receivedAt),
},
input: lifecycle.data,
})
return new NextResponse(null, { status: 200 })
}

const interactionTeam = payload.team as { id?: unknown } | undefined
const searchTeamId = typeof payload.team_id === 'string' ? payload.team_id : interactionTeam?.id
const searchInstallation =
Expand All @@ -83,6 +104,27 @@ async function handleSlackAppWebhook(request: NextRequest): Promise<NextResponse
input: { teamId: searchTeamId },
})
: null
const command = slackSearchCommandSchema.safeParse(payload)
if (command.success) {
if (!searchInstallation)
return NextResponse.json({
response_type: 'ephemeral',
text: 'An admin needs to install and enable Sim Search for this workspace.',
})
const { turnId, ...response } = await receiveSlackSearchCommand.execute({
principal: {
kind: 'slack_installation',
...searchInstallation,
appId,
teamId: command.data.team_id,
eventId: slackSearchCommandEventId(command.data),
receivedAt: new Date(receivedAt),
},
input: command.data,
})
if (turnId) after(() => dispatchSlackSearchTurn(turnId))
return NextResponse.json(response)
}
if (searchInstallation) {
await Promise.all([
dispatchSlackSearch({ ...searchInstallation, body, receivedAt }),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
import { useSearchSourceOverview, useSearchSources } from '@/hooks/queries/kb/connectors'
import { organizationAccountsKeys } from '@/hooks/queries/organization-accounts'
import { usePersonalSearchIntegrations } from '@/hooks/queries/personal-search-integrations'
import { useSearchIntegrations } from '@/hooks/queries/search-integrations'
import { searchSourceKeys } from '@/hooks/queries/utils/search-source-keys'
import { CONNECTABLE_MEMBERSHIPS, useMemberEnrollment } from '@/hooks/use-member-enrollment'
Expand All @@ -43,6 +44,14 @@ export function ConnectAccountOptions({
const sources = useSearchSources(scope, { search })
const overview = useSearchSourceOverview(scope)
const integrations = useSearchIntegrations(organization.id)
const slackInventory = usePersonalSearchIntegrations({
organizationId: organization.id,
connectorType: 'slack',
})
const canConnectSharedSlack =
slackInventory.data?.available.some(
(entry) => entry.target.connectorType === 'slack' && !entry.target.connectorId
) === true
const availability = usePermissionConfig()
const membershipQueryKeys = useMemo(
() => [
Expand Down Expand Up @@ -90,7 +99,7 @@ export function ConnectAccountOptions({
)
const sourceChoices = SEARCH_CONNECTORS.filter((connector) => {
if (
connector.type === 'slack' ||
(connector.type === 'slack' && !canConnectSharedSlack) ||
!approvedTypes.has(connector.type) ||
!connector.meta.name.toLowerCase().includes(search.toLowerCase()) ||
(configuredTypes.has(connector.type) && connector.setupFields.length === 0)
Expand Down Expand Up @@ -124,7 +133,9 @@ export function ConnectAccountOptions({
? overview
: integrations.isError
? integrations
: null
: slackInventory.isError
? slackInventory
: null

return (
<>
Expand All @@ -148,6 +159,7 @@ export function ConnectAccountOptions({
) : sources.isPending ||
overview.isPending ||
integrations.isPending ||
slackInventory.isPending ||
!availability.isIntegrationAvailabilityReady ? (
<SettingsEmptyState variant='inline'>Loading sources…</SettingsEmptyState>
) : visibleSources.length > 0 || sourceChoices.length > 0 || sources.hasNextPage ? (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ const mocks = vi.hoisted(() => ({
vi.mock('@/app/o/[organizationId]/integrations/slack-search-actions', () => ({
SlackSearchActions: ({ token }: { token: string }) => <button type='button'>{token}</button>,
}))
vi.mock('@/hooks/queries/personal-search-integrations', () => ({
usePersonalSearchIntegrations: () => ({
data: { available: [] },
isPending: false,
isError: false,
}),
}))
vi.mock('@/hooks/queries/search-integrations', () => ({
useSearchIntegrations: mocks.integrations,
}))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export function OrganizationSearchSlack() {
description='Connect your workspace to ask questions in Slack.'
trailing={
<Chip variant='primary' onClick={() => setWizard({})}>
Set up
{installations.data.sharedAppAvailable ? 'Install Sim Search' : 'Set up'}
</Chip>
}
/>
Expand Down
46 changes: 46 additions & 0 deletions apps/sim/components/integrations/slack-search-setup-wizard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,19 @@ export function SlackSearchSetupWizard({
}
}

const shared = Boolean(
prepare.data?.sharedAppId && (!configuredAppId || configuredAppId === prepare.data.sharedAppId)
)

function installShared() {
oauth.mutate(
{ organizationId, installationId, name, description, mode: 'shared' },
{
onSuccess: ({ authorizationUrl }) => window.location.assign(authorizationUrl),
}
)
}

function advance() {
if (step === 'manifest') {
setStep('credentials')
Expand All @@ -86,6 +99,39 @@ export function SlackSearchSetupWizard({
}
}

if (shared)
return (
<ChipModal
open
dismissDisabled={busy}
onOpenChange={(open) => {
if (!open) onClose()
}}
srTitle='Install Sim Search'
>
<ChipModalHeader icon={SlackIcon} onClose={onClose}>
Install Sim Search
</ChipModalHeader>
<ChipModalBody>
<ChipModalField type='custom' title='Connect your Slack workspace'>
<p className='text-[var(--text-secondary)] text-sm'>
Ask Sim in DMs or mention it in a channel. Each member connects their own Slack
account to index the channels and direct messages they choose to connect.
</p>
</ChipModalField>
<ChipModalError>{error?.message}</ChipModalError>
</ChipModalBody>
<ChipModalFooter
onCancel={onClose}
primaryAction={{
label: busy ? 'Connecting…' : 'Install Sim Search',
disabled: busy,
onClick: installShared,
}}
/>
</ChipModal>
)

return (
<ChipModal
open
Expand Down
4 changes: 4 additions & 0 deletions apps/sim/lib/api/contracts/knowledge/slack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@ export const slackSearchInstallationSchema = z.object({
appId: z.string().min(1).max(200),
teamId: z.string().min(1).max(200),
teamName: z.string().min(1).max(200),
appKind: z.enum(['custom', 'shared']),
enabled: z.boolean(),
needsValidation: z.boolean(),
lastOutcome: z.string().max(100).nullable(),
lastEventAt: z.string().datetime().nullable(),
})
export const listSlackSearchResponseSchema = z.object({
sharedAppAvailable: z.boolean(),
installations: z.array(slackSearchInstallationSchema).max(100),
bots: z
.array(z.object({ id: z.string().min(1).max(200), displayName: z.string().max(500) }))
Expand Down Expand Up @@ -64,6 +66,7 @@ export const prepareSlackSearchContract = defineRouteContract({
response: {
mode: 'json',
schema: z.object({
sharedAppId: z.string().min(1).max(200).nullable(),
manifest: z.string().max(20_000),
existingApp: z
.object({ appId: z.string().min(1).max(200), teamId: z.string().min(1).max(200) })
Expand All @@ -74,6 +77,7 @@ export const prepareSlackSearchContract = defineRouteContract({
})

export const startSlackSearchOAuthBodySchema = prepareSlackSearchBodySchema.extend({
mode: z.enum(['custom', 'shared']).default('custom'),
installationId: z.string().min(1).max(200).optional(),
clientId: z.string().trim().min(1).max(200).optional(),
clientSecret: z.string().trim().min(1).max(500).optional(),
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/lib/core/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -547,6 +547,8 @@ export const env = createEnv({
DROPBOX_CLIENT_ID: z.string().optional(), // Dropbox OAuth client ID
DROPBOX_CLIENT_SECRET: z.string().optional(), // Dropbox OAuth client secret
SLACK_CLIENT_ID: z.string().optional(), // Slack OAuth client ID
SLACK_SEARCH_APP_ID: z.string().optional(),
SLACK_SEARCH_SHARED_APP: z.boolean().optional(),
SLACK_CLIENT_SECRET: z.string().optional(), // Slack OAuth client secret
SLACK_SIGNING_SECRET: z.string().optional(), // Official Sim Slack app signing secret (verifies inbound events for the native OAuth trigger)
SLACK_EXTENDED_SCOPES: z.boolean().optional(), // Request app_mentions:read, assistant:write, im:history — only where the Slack app is approved for them
Expand Down
5 changes: 5 additions & 0 deletions apps/sim/lib/core/config/feature-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ interface FeatureFlagDefinition {

/** The single registry of known flags. To add a flag, add one entry here. */
const FEATURE_FLAGS = {
'slack-search-shared-app': {
description:
'Enable the official shared Slack app for existing Search customers. Global on/off only.',
fallback: 'SLACK_SEARCH_SHARED_APP',
},
'trigger-eu-region': {
description:
'Route Trigger.dev runs to eu-central-1 instead of the default us-east-1. Global on/off ' +
Expand Down
27 changes: 23 additions & 4 deletions apps/sim/lib/credential-groups/provider-configuration.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { db } from '@sim/db'
import { credentialGroup, slackApp } from '@sim/db/schema'
import { credentialGroup, slackApp, slackSearchInstallation } from '@sim/db/schema'
import { getErrorMessage } from '@sim/utils/errors'
import { and, eq, sql } from 'drizzle-orm'
import { and, eq, isNull, or, sql } from 'drizzle-orm'
import { resourceScopeFromOwner } from '@/lib/core/resource-scope'
import { resourceScopeCondition } from '@/lib/core/resource-scope.server'
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
import type { DbOrTx } from '@/lib/db/types'
import { requireSlackSearchAppAvailable } from '@/lib/slack-search/shared-app'

const CREDENTIAL_GROUP_PROVIDER_CONFIGURATION_TYPE =
'credential-group-provider-configuration' as const
Expand Down Expand Up @@ -148,12 +149,30 @@ async function resolveSlackConfiguration(
.where(
and(
eq(slackApp.id, configuration.appId),
eq(slackApp.organizationId, params.organizationId),
eq(slackApp.kind, 'custom')
or(
and(eq(slackApp.organizationId, params.organizationId), eq(slackApp.kind, 'custom')),
and(eq(slackApp.kind, 'shared'), isNull(slackApp.organizationId))
)
)
)
.limit(1)
if (!app) throw new Error('Organization Slack app configuration is missing')
if (app.kind === 'shared') {
await requireSlackSearchAppAvailable(app.id)
const [installation] = await (params.executor ?? db)
.select({ id: slackSearchInstallation.id })
.from(slackSearchInstallation)
.where(
and(
eq(slackSearchInstallation.slackAppId, app.id),
eq(slackSearchInstallation.organizationId, params.organizationId),
eq(slackSearchInstallation.teamId, configuration.teamId),
eq(slackSearchInstallation.enabled, true)
)
)
.limit(1)
if (!installation) throw new Error('The shared Slack installation is disabled or removed')
}
const { decrypted: clientSecret } = await decryptSecret(app.encryptedClientSecret)
return {
appId: app.id,
Expand Down
19 changes: 12 additions & 7 deletions apps/sim/lib/credential-groups/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,30 +244,34 @@ export async function getCredentialGroup(
export function ensureWorkspaceAccountsGroup(
scope: Extract<ResourceScope, { kind: 'organization' }>,
userId: string,
option?: CredentialGroupOptionInput
option?: CredentialGroupOptionInput,
executor?: DbOrTx
): Promise<OrganizationCredentialGroupRecord & { created: boolean }>
export function ensureWorkspaceAccountsGroup(
workspaceId: string,
userId: string,
option?: CredentialGroupOptionInput
option?: CredentialGroupOptionInput,
executor?: DbOrTx
): Promise<WorkspaceCredentialGroupRecord & { created: boolean }>
export function ensureWorkspaceAccountsGroup(
scope: ResourceScope,
userId: string,
option?: CredentialGroupOptionInput
option?: CredentialGroupOptionInput,
executor?: DbOrTx
): Promise<CredentialGroupRecord & { created: boolean }>
export async function ensureWorkspaceAccountsGroup(
scopeInput: string | ResourceScope,
userId: string,
option?: CredentialGroupOptionInput
option?: CredentialGroupOptionInput,
executor?: DbOrTx
): Promise<CredentialGroupRecord & { created: boolean }> {
const scope = credentialGroupScope(scopeInput)
if (option?.provider === 'slack') {
throw new OrchestrationError('validation', 'Configure Slack sign-in in Connected accounts')
}
const preparedOption = option ? await buildOption(scope, { ...option, required: false }) : null
let wasCreated = false
const row = await db.transaction(async (tx) => {
const provision = async (tx: DbOrTx) => {
await tx.execute(
sql`SELECT pg_advisory_xact_lock(hashtextextended(${`search-accounts:${resourceScopeKey(scope)}`}, 0))`
)
Expand Down Expand Up @@ -377,9 +381,10 @@ export async function ensureWorkspaceAccountsGroup(
)
wasCreated = true
return created
})
}
const row = executor ? await provision(executor) : await db.transaction(provision)
return {
...(await toCredentialGroup(row, await listLinkedMcpServers(row.id))),
...(await toCredentialGroup(row, await listLinkedMcpServers(row.id, executor))),
created: wasCreated,
}
}
Expand Down
Loading
Loading