feat(search): add shared Slack app installation and commands by TheodoreSpeaks · Pull Request #7743 · simstudioai/sim · GitHub
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
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
Loading
Loading