+
{visible.map((result) => {
const source = toSource(result, query)
return source ? (
@@ -316,6 +317,14 @@ export function KnowledgeSearchResults({
})}
)}
+ >
+ )
+
+ /** One keyboard container over both groups, so the arrows walk every result. */
+ return (
+
+ {knowledgeSection}
+
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/slack-search-results.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/slack-search-results.tsx
new file mode 100644
index 00000000000..e8776451c47
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/slack-search-results.tsx
@@ -0,0 +1,87 @@
+'use client'
+
+import type { SimSearchSlackResult } from '@/lib/api/contracts/knowledge'
+import { matchSnippet } from '@/lib/knowledge/search/snippet'
+import { SourceCard } from '@/app/workspace/[workspaceId]/home/components/message-content/components/source-card'
+import {
+ isHttpUrl,
+ type SourceTagData,
+} from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
+import { useSimSearchSlack } from '@/hooks/queries/kb/knowledge'
+
+function toSource(result: SimSearchSlackResult, query: string): SourceTagData | null {
+ if (!isHttpUrl(result.permalink)) return null
+ return {
+ url: result.permalink,
+ title: `#${result.channelName}`,
+ siteName: 'Slack',
+ connectorType: 'slack',
+ snippet: matchSnippet(result.text, query),
+ author: result.authorName,
+ updatedAt: result.sentAt ?? undefined,
+ }
+}
+
+interface SlackSearchResultsProps {
+ workspaceId: string
+ query: string
+ /** Asks the agent about one message; the prompt names it and links to it. */
+ onSummarize: (prompt: string) => void
+}
+
+/**
+ * Slack messages matching the query, searched live as the signed-in person.
+ *
+ * Its own group rather than blended into the indexed results: Slack ranks with
+ * its own relevance, which is not comparable to a vector distance, so
+ * interleaving the two lists would present an ordering that means nothing.
+ * Nothing renders while Slack is unconnected — the Sources strip is where a
+ * person connects it — but a connection that stopped working says so, because
+ * reconnecting is something they can act on.
+ */
+export function SlackSearchResults({ workspaceId, query, onSummarize }: SlackSearchResultsProps) {
+ const { data, isPending, isPlaceholderData } = useSimSearchSlack(workspaceId, query)
+
+ if (!data || isPending || isPlaceholderData) return null
+ if (data.status === 'not_connected') return null
+
+ if (data.status === 'needs_reauth') {
+ return (
+
+ Reconnect Slack from Sources to search it.
+
+ )
+ }
+ if (data.status === 'unavailable') {
+ return (
+
+ Slack could not be searched this time.
+
+ )
+ }
+ if (data.results.length === 0) return null
+
+ return (
+
+
+
+ {data.results.length === 1 ? '1 Slack message' : `${data.results.length} Slack messages`}
+
+ {' · searched in Slack as you'}
+
+ {data.results.map((result) => {
+ const source = toSource(result, query)
+ return source ? (
+
+ onSummarize(`Summarize this Slack message from ${cited.title} (${cited.url})`)
+ }
+ />
+ ) : null
+ })}
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/search-sources/search-sources.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/search-sources/search-sources.tsx
index 9bf5db007a8..30d7b48eafa 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/search-sources/search-sources.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/search-sources/search-sources.tsx
@@ -18,6 +18,7 @@ import {
useWorkspaceMemberConnectors,
type WorkspaceMemberConnector,
} from '@/hooks/queries/kb/connectors'
+import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys'
import { useWorkspacePermissionsQuery } from '@/hooks/queries/workspace'
import { CONNECTABLE_MEMBERSHIPS, useMemberEnrollment } from '@/hooks/use-member-enrollment'
import { usePermissionConfig } from '@/hooks/use-permission-config'
@@ -169,7 +170,15 @@ export function SearchSources({ workspaceId }: SearchSourcesProps) {
),
[memberConnectors]
)
- const membershipQueryKeys = useMemo(() => [memberConnectorKeys.list(workspaceId)], [workspaceId])
+ /**
+ * What a finished connection changes. The federated Slack search is here
+ * too: it holds its answer briefly, and someone who just reconnected Slack
+ * should stop being told to reconnect it rather than wait that out.
+ */
+ const membershipQueryKeys = useMemo(
+ () => [memberConnectorKeys.list(workspaceId), knowledgeKeys.slackSearches()],
+ [workspaceId]
+ )
const {
connectSource,
connectSearchSource,
diff --git a/apps/sim/hooks/queries/kb/knowledge.ts b/apps/sim/hooks/queries/kb/knowledge.ts
index fe885869cfa..fd1af8a55d9 100644
--- a/apps/sim/hooks/queries/kb/knowledge.ts
+++ b/apps/sim/hooks/queries/kb/knowledge.ts
@@ -40,7 +40,9 @@ import {
nextAvailableSlotContract,
restoreKnowledgeBaseContract,
type SaveDocumentTagDefinitionsResult,
+ type SearchSimSearchSlackBody,
saveDocumentTagDefinitionsContract,
+ searchSimSearchSlackContract,
searchWorkspaceKnowledgeContract,
type TagDefinitionData,
type TagUsageData,
@@ -52,6 +54,7 @@ import {
type WorkspaceKnowledgeSearchBody,
type WorkspaceKnowledgeSearchResult,
} from '@/lib/api/contracts/knowledge'
+import { useSession } from '@/lib/auth/auth-client'
import type { ChunkingStrategy, StrategyOptions } from '@/lib/chunkers/types'
import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types'
import { folderKeys } from '@/hooks/queries/utils/folder-keys'
@@ -78,6 +81,8 @@ export const KNOWLEDGE_DOCUMENT_LIST_STALE_TIME = 60 * 1000
export const KNOWLEDGE_CHUNK_LIST_STALE_TIME = 60 * 1000
export const KNOWLEDGE_CHUNK_SEARCH_STALE_TIME = 60 * 1000
export const WORKSPACE_KNOWLEDGE_SEARCH_STALE_TIME = 60 * 1000
+/** Slack answers live, so a result is only reused for as long as a person keeps typing one query. */
+export const SIM_SEARCH_SLACK_STALE_TIME = 30 * 1000
export const KNOWLEDGE_TAG_DEFINITION_LIST_STALE_TIME = 60 * 1000
export const KNOWLEDGE_TAG_USAGE_STALE_TIME = 60 * 1000
export const KNOWLEDGE_DOCUMENT_TAG_DEFINITION_LIST_STALE_TIME = 60 * 1000
@@ -1192,3 +1197,31 @@ export function useWorkspaceKnowledgeSearch(
placeholderData: keepPreviousData,
})
}
+
+async function searchSlack(body: SearchSimSearchSlackBody, signal?: AbortSignal) {
+ const data = await requestJson(searchSimSearchSlackContract, { body, signal })
+ return data.data
+}
+
+/**
+ * What Slack returns for `query`, searched live as the signed-in person.
+ *
+ * Slack is not indexed, so this asks Slack itself on every new query. The
+ * answer is short-lived on purpose: a conversation that moved on since the
+ * last search should not be served from a cache, and Slack's own per-person
+ * rate limit is what keeps the call rate sane.
+ */
+export function useSimSearchSlack(workspaceId: string | undefined, query: string) {
+ const trimmed = query.trim()
+ const { data: session } = useSession()
+ const viewerId = session?.user?.id
+ return useQuery({
+ queryKey: knowledgeKeys.slackSearch(workspaceId, viewerId, trimmed),
+ queryFn: ({ signal }) =>
+ searchSlack({ workspaceId: workspaceId as string, query: trimmed }, signal),
+ /** Held until the viewer is known, so no answer is ever cached under an empty identity. */
+ enabled: Boolean(workspaceId) && Boolean(viewerId) && trimmed.length > 0,
+ staleTime: SIM_SEARCH_SLACK_STALE_TIME,
+ placeholderData: keepPreviousData,
+ })
+}
diff --git a/apps/sim/hooks/queries/utils/knowledge-keys.ts b/apps/sim/hooks/queries/utils/knowledge-keys.ts
index b4d2611d14a..25ac5078ac8 100644
--- a/apps/sim/hooks/queries/utils/knowledge-keys.ts
+++ b/apps/sim/hooks/queries/utils/knowledge-keys.ts
@@ -31,6 +31,14 @@ export const knowledgeKeys = {
detail: (knowledgeBaseId?: string) =>
[...knowledgeKeys.details(), knowledgeBaseId ?? ''] as const,
searches: () => [...knowledgeKeys.all, 'search'] as const,
+ slackSearches: () => [...knowledgeKeys.all, 'slackSearch'] as const,
+ /**
+ * Keyed by viewer as well as workspace: the answer is one person's own Slack,
+ * including their direct messages, so a session change in an open tab must
+ * never be served another person's cached results.
+ */
+ slackSearch: (workspaceId: string | undefined, userId: string | undefined, query: string) =>
+ [...knowledgeKeys.slackSearches(), workspaceId ?? '', userId ?? '', query] as const,
search: (workspaceId: string | undefined, knowledgeBaseIds: readonly string[], query: string) =>
[
...knowledgeKeys.searches(),
diff --git a/apps/sim/lib/api/contracts/knowledge/index.ts b/apps/sim/lib/api/contracts/knowledge/index.ts
index 93d76509b50..2f593ff65f9 100644
--- a/apps/sim/lib/api/contracts/knowledge/index.ts
+++ b/apps/sim/lib/api/contracts/knowledge/index.ts
@@ -3,4 +3,5 @@ export * from '@/lib/api/contracts/knowledge/chunks'
export * from '@/lib/api/contracts/knowledge/connectors'
export * from '@/lib/api/contracts/knowledge/documents'
export * from '@/lib/api/contracts/knowledge/search'
+export * from '@/lib/api/contracts/knowledge/sim-search'
export * from '@/lib/api/contracts/knowledge/tags'
diff --git a/apps/sim/lib/api/contracts/knowledge/sim-search.ts b/apps/sim/lib/api/contracts/knowledge/sim-search.ts
new file mode 100644
index 00000000000..dda3ca7ce32
--- /dev/null
+++ b/apps/sim/lib/api/contracts/knowledge/sim-search.ts
@@ -0,0 +1,66 @@
+import { z } from 'zod'
+import { workspaceIdSchema } from '@/lib/api/contracts/primitives'
+import { defineRouteContract } from '@/lib/api/contracts/types'
+import { SLACK_SEARCH_MAX_LIMIT } from '@/lib/slack-search/client'
+
+export const MAX_SIM_SEARCH_SLACK_QUERY_LENGTH = 2000
+
+export const slackSearchBodySchema = z.object({
+ workspaceId: workspaceIdSchema,
+ query: z
+ .string()
+ .min(1, 'query cannot be empty')
+ .max(
+ MAX_SIM_SEARCH_SLACK_QUERY_LENGTH,
+ `query cannot exceed ${MAX_SIM_SEARCH_SLACK_QUERY_LENGTH} characters`
+ ),
+ limit: z.number().int().min(1).max(SLACK_SEARCH_MAX_LIMIT).optional(),
+})
+
+export const slackSearchResultSchema = z.object({
+ channelId: z.string(),
+ messageTs: z.string(),
+ channelName: z.string(),
+ authorName: z.string(),
+ text: z.string(),
+ permalink: z.string().url(),
+ /** ISO-8601, or null when Slack returned a timestamp that would not parse. */
+ sentAt: z.string().nullable(),
+})
+
+/**
+ * Whether Slack answered, and if not, what the person can do about it.
+ * `needs_reauth` is its own state because reconnecting is a real action they
+ * can take, where `unavailable` is nothing they can fix.
+ */
+export const slackSearchStatusSchema = z.enum([
+ 'ok',
+ 'not_connected',
+ 'needs_reauth',
+ 'unavailable',
+])
+
+/**
+ * Searching Slack for the person asking, live, under their own Slack account.
+ * Nothing is indexed and no result is stored, so this is a read that returns
+ * exactly what that person could have found in Slack themselves.
+ */
+export const searchSimSearchSlackContract = defineRouteContract({
+ method: 'POST',
+ path: '/api/knowledge/sim-search/slack',
+ body: slackSearchBodySchema,
+ response: {
+ mode: 'json',
+ schema: z.object({
+ success: z.literal(true),
+ data: z.object({
+ status: slackSearchStatusSchema,
+ results: z.array(slackSearchResultSchema),
+ }),
+ }),
+ },
+})
+
+export type SearchSimSearchSlackBody = z.input
+export type SimSearchSlackResult = z.output
+export type SimSearchSlackStatus = z.output
diff --git a/apps/sim/lib/credential-groups/slack-managed-user-scopes.ts b/apps/sim/lib/credential-groups/slack-managed-user-scopes.ts
index 3358297be25..ee5492aaf54 100644
--- a/apps/sim/lib/credential-groups/slack-managed-user-scopes.ts
+++ b/apps/sim/lib/credential-groups/slack-managed-user-scopes.ts
@@ -22,6 +22,20 @@ export const SLACK_MANAGED_USER_SCOPES = [
'mpim:write',
'reactions:read',
'reactions:write',
+ /**
+ * Federated Slack search (`assistant.search.context`). Slack requires at
+ * least `search:read.public`; each other scope widens what the search covers,
+ * and the four together let a person's own search reach exactly the
+ * conversations they can already read in Slack. `search:read.files` and
+ * `search:read.users` are deliberately absent: this searches messages, and an
+ * unused scope is one more thing every member is asked to grant.
+ *
+ * https://docs.slack.dev/ai/using-data-access-api
+ */
+ 'search:read.public',
+ 'search:read.private',
+ 'search:read.im',
+ 'search:read.mpim',
'users.profile:read',
'users.profile:write',
'users:read',
diff --git a/apps/sim/lib/knowledge/application/operations.test.ts b/apps/sim/lib/knowledge/application/operations.test.ts
index 53cdac8308f..e806a0e8698 100644
--- a/apps/sim/lib/knowledge/application/operations.test.ts
+++ b/apps/sim/lib/knowledge/application/operations.test.ts
@@ -59,6 +59,7 @@ describe('knowledge operation registry', () => {
'knowledge.connectors.members.list',
'knowledge.connectors.members.enroll',
'knowledge.simSearch.connect',
+ 'knowledge.simSearch.federated',
'knowledge.connectors.delete',
'knowledge.connectors.sync',
'knowledge.connectors.documents.list',
diff --git a/apps/sim/lib/knowledge/application/operations.ts b/apps/sim/lib/knowledge/application/operations.ts
index 0228b0d2fd8..8bff429cc0e 100644
--- a/apps/sim/lib/knowledge/application/operations.ts
+++ b/apps/sim/lib/knowledge/application/operations.ts
@@ -430,6 +430,19 @@ export const knowledgeOperations = {
capability: 'knowledge.use',
principalKinds: ['session'],
}),
+ /**
+ * Searching a federated Sim Search source, which runs under the asking
+ * person's own connected account. There is no actorless form of it — without
+ * a person there is no account to search as — so it is session-only, exactly
+ * like connecting one.
+ */
+ simSearchFederated: defineWorkspaceOperation({
+ id: 'knowledge.simSearch.federated',
+ minimumRole: 'read',
+ workspaceApiKey: 'deny',
+ capability: 'knowledge.use',
+ principalKinds: ['session'],
+ }),
deleteConnector: defineWorkspaceOperation({
id: 'knowledge.connectors.delete',
minimumRole: 'write',
diff --git a/apps/sim/lib/knowledge/application/sim-search.ts b/apps/sim/lib/knowledge/application/sim-search.ts
index 5123723e4d1..fecba80c680 100644
--- a/apps/sim/lib/knowledge/application/sim-search.ts
+++ b/apps/sim/lib/knowledge/application/sim-search.ts
@@ -29,6 +29,7 @@ import {
missingSetupFields,
SIM_SEARCH_KNOWLEDGE_BASE_NAME,
} from '@/lib/sim-search/connectors'
+import { type SlackSearchOutcome, searchSlackForViewer } from '@/lib/slack-search/search'
import { CONNECTOR_META_REGISTRY } from '@/connectors/registry'
const SIM_SEARCH_KNOWLEDGE_BASE_DESCRIPTION =
@@ -238,3 +239,64 @@ export const connectSimSearchConnector = defineAuthorizedKnowledgeUseCase({
return { ...target, url }
},
})
+
+export interface SearchSimSearchSlackInput {
+ workspaceId: string
+ query: string
+ limit?: number
+}
+
+export interface SearchSimSearchSlackResult {
+ status: SlackSearchOutcome['status']
+ results: {
+ channelId: string
+ messageTs: string
+ channelName: string
+ authorName: string
+ text: string
+ permalink: string
+ sentAt: string | null
+ }[]
+}
+
+/**
+ * Searches Slack for the person asking, live, under their own connected Slack
+ * account.
+ *
+ * Federated rather than indexed: nothing was crawled, so there is no document
+ * ACL to consult. Slack answers from its own index under that person's token
+ * and enforces its own permissions, which is why the result is exactly what
+ * they could have found in Slack themselves. The workspace authorization above
+ * still applies, so someone who left the workspace stops searching through it
+ * even while their Slack account keeps working.
+ */
+export const searchSimSearchSlack = defineAuthorizedKnowledgeUseCase({
+ operation: knowledgeOperations.simSearchFederated,
+ resolveContext: ({ input }: { input: SearchSimSearchSlackInput }) =>
+ resolveKnowledgeWorkspaceContext(input),
+ async execute({ principal, input, context }): Promise {
+ const userId = resolvePrincipalSubjectUserId(principal)
+ if (!userId) throw new OrchestrationError('forbidden', 'Sign in to search your connected Slack')
+
+ const outcome = await searchSlackForViewer({
+ workspaceId: context.workspaceId,
+ userId,
+ query: input.query,
+ ...(input.limit !== undefined ? { limit: input.limit } : {}),
+ })
+ if (outcome.status !== 'ok') return { status: outcome.status, results: [] }
+
+ return {
+ status: 'ok',
+ results: outcome.results.map((result) => ({
+ channelId: result.channelId,
+ messageTs: result.messageTs,
+ channelName: result.channelName,
+ authorName: result.authorName,
+ text: result.text,
+ permalink: result.permalink,
+ sentAt: result.sentAt?.toISOString() ?? null,
+ })),
+ }
+ },
+})
diff --git a/apps/sim/lib/slack-search/client.test.ts b/apps/sim/lib/slack-search/client.test.ts
new file mode 100644
index 00000000000..aed08d7ec11
--- /dev/null
+++ b/apps/sim/lib/slack-search/client.test.ts
@@ -0,0 +1,144 @@
+/**
+ * @vitest-environment node
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { SLACK_SEARCH_MAX_LIMIT, SlackSearchError, searchSlack } from '@/lib/slack-search/client'
+
+const MESSAGE = {
+ author_name: 'Ada Lovelace',
+ author_user_id: 'U1',
+ channel_id: 'C0GENERAL',
+ channel_name: 'general',
+ message_ts: '1700000200.000100',
+ content: 'The deploy is green',
+ is_author_bot: false,
+ permalink: 'https://example.slack.com/archives/C0GENERAL/p1700000200000100',
+}
+
+const fetchMock = vi.fn<(input: string | URL | Request, init?: RequestInit) => Promise>()
+
+/** The body returned by `assistant.search.context`; per-test overridable. */
+let responseBody: unknown = { ok: true, results: { messages: [MESSAGE] } }
+
+function lastRequestBody(): URLSearchParams {
+ const init = fetchMock.mock.calls.at(-1)?.[1]
+ return new URLSearchParams(String(init?.body))
+}
+
+beforeEach(() => {
+ responseBody = { ok: true, results: { messages: [MESSAGE] } }
+ fetchMock.mockReset()
+ fetchMock.mockImplementation(
+ async () =>
+ new Response(JSON.stringify(responseBody), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ })
+ )
+ vi.stubGlobal('fetch', fetchMock)
+})
+
+afterEach(() => {
+ vi.unstubAllGlobals()
+})
+
+describe('searchSlack', () => {
+ it('asks Slack for messages across every conversation kind the person can read', async () => {
+ await searchSlack({ accessToken: 'xoxp-token', query: ' deploy ' })
+
+ const [url, init] = fetchMock.mock.calls[0]
+ expect(String(url)).toBe('https://slack.com/api/assistant.search.context')
+ expect(init?.method).toBe('POST')
+ expect((init?.headers as Record).Authorization).toBe('Bearer xoxp-token')
+
+ const body = lastRequestBody()
+ expect(body.get('query')).toBe('deploy')
+ expect(body.get('content_types')).toBe('messages')
+ expect(body.get('channel_types')).toBe('public_channel,private_channel,mpim,im')
+ expect(body.get('include_context_messages')).toBe('true')
+ })
+
+ it('never asks for more than Slack will return', async () => {
+ await searchSlack({ accessToken: 'token', query: 'deploy', limit: 500 })
+ expect(lastRequestBody().get('limit')).toBe(String(SLACK_SEARCH_MAX_LIMIT))
+ })
+
+ it('reads nothing for an empty query', async () => {
+ await expect(searchSlack({ accessToken: 'token', query: ' ' })).resolves.toEqual([])
+ expect(fetchMock).not.toHaveBeenCalled()
+ })
+
+ it('normalizes a hit and reads it together with the messages around it', async () => {
+ responseBody = {
+ ok: true,
+ results: {
+ messages: [
+ {
+ ...MESSAGE,
+ context_messages: {
+ before: [{ text: 'Is staging up?' }],
+ after: [{ text: 'Thanks!' }, { text: ' ' }],
+ },
+ },
+ ],
+ },
+ }
+
+ await expect(searchSlack({ accessToken: 'token', query: 'deploy' })).resolves.toEqual([
+ {
+ channelId: 'C0GENERAL',
+ messageTs: '1700000200.000100',
+ channelName: 'general',
+ authorName: 'Ada Lovelace',
+ text: 'Is staging up?\nThe deploy is green\nThanks!',
+ permalink: MESSAGE.permalink,
+ sentAt: new Date('2023-11-14T22:16:40.000Z'),
+ isAuthorBot: false,
+ },
+ ])
+ })
+
+ it('drops a message that cannot be cited or has nothing to read', async () => {
+ responseBody = {
+ ok: true,
+ results: {
+ messages: [
+ { ...MESSAGE, permalink: undefined },
+ { ...MESSAGE, message_ts: undefined },
+ { ...MESSAGE, content: ' ', context_messages: { before: [], after: [] } },
+ MESSAGE,
+ ],
+ },
+ }
+
+ const results = await searchSlack({ accessToken: 'token', query: 'deploy' })
+ expect(results).toHaveLength(1)
+ expect(results[0].channelId).toBe('C0GENERAL')
+ })
+
+ it('keeps a result whose timestamp will not parse, without a date', async () => {
+ responseBody = { ok: true, results: { messages: [{ ...MESSAGE, message_ts: 'not-a-ts' }] } }
+ const results = await searchSlack({ accessToken: 'token', query: 'deploy' })
+ expect(results).toHaveLength(1)
+ expect(results[0].sentAt).toBeNull()
+ })
+
+ it('raises Slack’s own error code when it refuses', async () => {
+ responseBody = { ok: false, error: 'missing_scope' }
+ await expect(searchSlack({ accessToken: 'token', query: 'deploy' })).rejects.toThrow(
+ SlackSearchError
+ )
+ await expect(searchSlack({ accessToken: 'token', query: 'deploy' })).rejects.toThrow(
+ 'missing_scope'
+ )
+ })
+
+ it('raises for a transport failure, releasing the unread body', async () => {
+ const response = new Response('nope', { status: 429 })
+ const cancel = vi.spyOn(response.body as ReadableStream, 'cancel')
+ fetchMock.mockResolvedValue(response)
+
+ await expect(searchSlack({ accessToken: 'token', query: 'deploy' })).rejects.toThrow('http_429')
+ expect(cancel).toHaveBeenCalled()
+ })
+})
diff --git a/apps/sim/lib/slack-search/client.ts b/apps/sim/lib/slack-search/client.ts
new file mode 100644
index 00000000000..fce8ccc97b7
--- /dev/null
+++ b/apps/sim/lib/slack-search/client.ts
@@ -0,0 +1,176 @@
+import { createLogger } from '@sim/logger'
+import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits'
+import {
+ SLACK_SEARCH_CHANNEL_TYPES,
+ type SlackSearchChannelType,
+ type SlackSearchResult,
+} from '@/lib/slack-search/types'
+
+const logger = createLogger('SlackSearch')
+
+const SLACK_SEARCH_URL = 'https://slack.com/api/assistant.search.context'
+
+/** Slack caps a page of search context at twenty results. */
+export const SLACK_SEARCH_MAX_LIMIT = 20
+
+/** A search response holds a bounded number of messages; this guards a malformed body. */
+const MAX_RESPONSE_BYTES = 4 * 1024 * 1024
+
+/**
+ * Slack's rate limit for this method is roughly ten calls a minute per person,
+ * and the caller is a person waiting on a search box, so a slow answer is worth
+ * less than a fast one without Slack in it.
+ */
+const REQUEST_TIMEOUT_MS = 8_000
+
+/** A Slack `ok: false` envelope, carrying the machine-readable code. */
+export class SlackSearchError extends Error {
+ constructor(readonly code: string) {
+ super(`Slack search failed: ${code}`)
+ this.name = 'SlackSearchError'
+ }
+}
+
+/**
+ * The message shape `assistant.search.context` returns. Only the fields this
+ * module reads are modeled.
+ *
+ * https://docs.slack.dev/reference/methods/assistant.search.context/
+ */
+interface SlackContextMessage {
+ author_name?: string
+ author_user_id?: string
+ channel_id?: string
+ channel_name?: string
+ message_ts?: string
+ content?: string
+ is_author_bot?: boolean
+ permalink?: string
+ context_messages?: {
+ before?: { text?: string }[]
+ after?: { text?: string }[]
+ }
+}
+
+interface SlackSearchResponse {
+ ok?: boolean
+ error?: string
+ results?: { messages?: SlackContextMessage[] }
+ response_metadata?: { next_cursor?: string }
+}
+
+/**
+ * A Slack timestamp is epoch seconds with a fractional suffix. An unparseable
+ * one costs the result its date, never the result itself.
+ */
+function parseSlackTimestamp(ts: string | undefined): Date | null {
+ const seconds = Number.parseFloat(ts ?? '')
+ if (!Number.isFinite(seconds)) return null
+ const date = new Date(seconds * 1000)
+ return Number.isNaN(date.getTime()) ? null : date
+}
+
+/**
+ * Joins the matched message with the messages Slack returned around it, so a
+ * one-line reply reads as the exchange it belongs to rather than on its own.
+ */
+function buildText(message: SlackContextMessage): string {
+ const before = message.context_messages?.before ?? []
+ const after = message.context_messages?.after ?? []
+ const lines = [
+ ...before.map((entry) => entry.text?.trim()),
+ message.content?.trim(),
+ ...after.map((entry) => entry.text?.trim()),
+ ]
+ return lines.filter((line): line is string => Boolean(line)).join('\n')
+}
+
+function toResult(message: SlackContextMessage): SlackSearchResult | null {
+ const channelId = message.channel_id
+ const messageTs = message.message_ts
+ const permalink = message.permalink
+ if (!channelId || !messageTs || !permalink) return null
+ const text = buildText(message)
+ if (!text) return null
+ return {
+ channelId,
+ messageTs,
+ channelName: message.channel_name ?? 'Slack',
+ authorName: message.author_name ?? 'Unknown',
+ text,
+ permalink,
+ sentAt: parseSlackTimestamp(messageTs),
+ isAuthorBot: message.is_author_bot === true,
+ }
+}
+
+export interface SearchSlackParams {
+ /** The asking person's own Slack user token; Slack enforces what it may read. */
+ accessToken: string
+ query: string
+ /** Results to return, capped at Slack's own maximum. */
+ limit?: number
+ channelTypes?: readonly SlackSearchChannelType[]
+ signal?: AbortSignal
+}
+
+/**
+ * Searches Slack for context matching a query, as the holder of the token.
+ *
+ * Nothing is indexed: Slack answers from its own index and enforces its own
+ * permissions, so a result is one the person could have found in Slack itself.
+ * Failures are the caller's to absorb — a federated leg that throws must not
+ * take the rest of a search down with it.
+ */
+export async function searchSlack({
+ accessToken,
+ query,
+ limit = SLACK_SEARCH_MAX_LIMIT,
+ channelTypes = SLACK_SEARCH_CHANNEL_TYPES,
+ signal,
+}: SearchSlackParams): Promise {
+ const trimmed = query.trim()
+ if (!trimmed) return []
+
+ const body = new URLSearchParams({
+ query: trimmed,
+ limit: String(Math.min(Math.max(1, limit), SLACK_SEARCH_MAX_LIMIT)),
+ content_types: 'messages',
+ channel_types: channelTypes.join(','),
+ include_context_messages: 'true',
+ include_bots: 'false',
+ })
+
+ const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS)
+ const response = await fetch(SLACK_SEARCH_URL, {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ },
+ body,
+ signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
+ })
+
+ if (!response.ok) {
+ /** Nothing here reads the body, and an uncancelled one holds the connection open. */
+ await response.body?.cancel().catch(() => {})
+ throw new SlackSearchError(`http_${response.status}`)
+ }
+
+ const data = await readResponseJsonWithLimit(response, {
+ maxBytes: MAX_RESPONSE_BYTES,
+ label: 'Slack search response',
+ })
+
+ if (!data.ok) {
+ throw new SlackSearchError(data.error || 'unknown_error')
+ }
+
+ const results = (data.results?.messages ?? []).flatMap((message) => {
+ const result = toResult(message)
+ return result ? [result] : []
+ })
+ logger.info('Searched Slack', { returned: results.length })
+ return results
+}
diff --git a/apps/sim/lib/slack-search/credentials.ts b/apps/sim/lib/slack-search/credentials.ts
new file mode 100644
index 00000000000..1f240750111
--- /dev/null
+++ b/apps/sim/lib/slack-search/credentials.ts
@@ -0,0 +1,67 @@
+import { db } from '@sim/db'
+import { credential, credentialGroup, credentialGroupEnrollment, user } from '@sim/db/schema'
+import { and, eq, inArray, sql } from 'drizzle-orm'
+import { LIVE_ENROLLMENT_STATUSES } from '@/lib/credential-groups/credentials'
+import { getCredentialGroupProviderId } from '@/lib/credential-groups/providers'
+
+/**
+ * The Slack account a federated search runs as: the asking person's own,
+ * enrolled through a Credential Group in this workspace.
+ *
+ * Deliberately does not filter on `managedOauthStatus`. A credential that needs
+ * authorizing again is still a connection the person made, and the difference
+ * between "you never connected Slack" and "reconnect Slack" is the whole of
+ * what the surface can tell them to do. Excluding it here would collapse the
+ * second into the first — which is exactly what a scope-policy change does to
+ * every enrolled credential at once. `resolveManagedOAuthToken` classifies it,
+ * and an active credential is preferred when a person somehow holds several.
+ *
+ * The group must belong to this workspace as well as the credential: the two
+ * are set together today, and requiring both keeps a workspace's search inside
+ * its own groups even if they ever diverge.
+ */
+export async function findViewerSlackCredentialId(params: {
+ workspaceId: string
+ userId: string
+}): Promise {
+ const [row] = await db
+ .select({ credentialId: credential.id })
+ .from(user)
+ .innerJoin(
+ credentialGroupEnrollment,
+ and(
+ eq(
+ credentialGroupEnrollment.email,
+ sql`COALESCE(${user.normalizedEmail}, lower(btrim(${user.email})))`
+ ),
+ inArray(credentialGroupEnrollment.status, [...LIVE_ENROLLMENT_STATUSES])
+ )
+ )
+ .innerJoin(
+ credentialGroup,
+ and(
+ eq(credentialGroup.id, credentialGroupEnrollment.credentialGroupId),
+ eq(credentialGroup.workspaceId, params.workspaceId),
+ eq(credentialGroup.status, 'active')
+ )
+ )
+ .innerJoin(
+ credential,
+ and(
+ eq(credential.credentialGroupEnrollmentId, credentialGroupEnrollment.id),
+ eq(credential.workspaceId, params.workspaceId),
+ eq(credential.type, 'managed_oauth'),
+ eq(credential.providerId, getCredentialGroupProviderId('slack')),
+ sql`EXISTS (
+ SELECT 1 FROM jsonb_array_elements(${credentialGroup.options}) AS option
+ WHERE option->>'id' = ${credential.credentialGroupOptionId}
+ AND option->>'status' = 'active'
+ )`
+ )
+ )
+ .where(and(eq(user.id, params.userId), eq(user.emailVerified, true)))
+ .orderBy(sql`CASE WHEN ${credential.managedOauthStatus} = 'active' THEN 0 ELSE 1 END`)
+ .limit(1)
+
+ return row?.credentialId ?? null
+}
diff --git a/apps/sim/lib/slack-search/search.test.ts b/apps/sim/lib/slack-search/search.test.ts
new file mode 100644
index 00000000000..e8831f6621b
--- /dev/null
+++ b/apps/sim/lib/slack-search/search.test.ts
@@ -0,0 +1,104 @@
+/**
+ * @vitest-environment node
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mocks = vi.hoisted(() => ({
+ findViewerSlackCredentialId: vi.fn(),
+ resolveManagedOAuthToken: vi.fn(),
+ searchSlack: vi.fn(),
+}))
+
+vi.mock('@/lib/slack-search/credentials', () => ({
+ findViewerSlackCredentialId: mocks.findViewerSlackCredentialId,
+}))
+vi.mock('@/lib/slack-search/client', () => ({
+ searchSlack: mocks.searchSlack,
+}))
+vi.mock('@/lib/credentials/managed-oauth', async () => {
+ const actual = await import('@/lib/credentials/managed-oauth')
+ return { ...actual, resolveManagedOAuthToken: mocks.resolveManagedOAuthToken }
+})
+
+import { ManagedOAuthCredentialError } from '@/lib/credentials/managed-oauth'
+import { searchSlackForViewer } from '@/lib/slack-search/search'
+
+const RESULT = {
+ channelId: 'C1',
+ messageTs: '1700000200.000100',
+ channelName: 'general',
+ authorName: 'Ada',
+ text: 'green',
+ permalink: 'https://example.slack.com/archives/C1/p1',
+ sentAt: null,
+ isAuthorBot: false,
+}
+
+const params = { workspaceId: 'ws-1', userId: 'user-1', query: 'deploy' }
+
+beforeEach(() => {
+ mocks.findViewerSlackCredentialId.mockReset().mockResolvedValue('cred-1')
+ mocks.resolveManagedOAuthToken
+ .mockReset()
+ .mockResolvedValue({ accessToken: 'xoxp', refreshed: false })
+ mocks.searchSlack.mockReset().mockResolvedValue([RESULT])
+})
+
+describe('searchSlackForViewer', () => {
+ it('searches Slack under the asking person’s own token', async () => {
+ await expect(searchSlackForViewer(params)).resolves.toEqual({
+ status: 'ok',
+ results: [RESULT],
+ })
+ expect(mocks.searchSlack).toHaveBeenCalledWith(
+ expect.objectContaining({ accessToken: 'xoxp', query: 'deploy' })
+ )
+ })
+
+ it('only ever asks for the search scopes', async () => {
+ await searchSlackForViewer(params)
+ const { requiredScopes } = mocks.resolveManagedOAuthToken.mock.calls[0][0]
+ expect(requiredScopes).toEqual([
+ 'search:read.public',
+ 'search:read.private',
+ 'search:read.im',
+ 'search:read.mpim',
+ ])
+ })
+
+ it('reports a person who has not connected Slack, without calling Slack', async () => {
+ mocks.findViewerSlackCredentialId.mockResolvedValue(null)
+ await expect(searchSlackForViewer(params)).resolves.toEqual({ status: 'not_connected' })
+ expect(mocks.resolveManagedOAuthToken).not.toHaveBeenCalled()
+ expect(mocks.searchSlack).not.toHaveBeenCalled()
+ })
+
+ it.each([
+ ['MANAGED_CREDENTIAL_NEEDS_REAUTH', 'needs_reauth'],
+ ['MANAGED_CREDENTIAL_INSUFFICIENT_SCOPE', 'needs_reauth'],
+ ['MANAGED_CREDENTIAL_REVOKED', 'needs_reauth'],
+ ['MANAGED_CREDENTIAL_NOT_FOUND', 'not_connected'],
+ ['MANAGED_CREDENTIAL_REFRESH_FAILED', 'unavailable'],
+ ] as const)('turns %s into %s', async (code, status) => {
+ mocks.resolveManagedOAuthToken.mockRejectedValue(
+ new ManagedOAuthCredentialError(code, 'nope', 401)
+ )
+ await expect(searchSlackForViewer(params)).resolves.toEqual({ status })
+ })
+
+ it('absorbs a Slack failure rather than taking the search down with it', async () => {
+ mocks.searchSlack.mockRejectedValue(new Error('ratelimited'))
+ await expect(searchSlackForViewer(params)).resolves.toEqual({ status: 'unavailable' })
+ })
+
+ it('absorbs an unexpected credential failure', async () => {
+ mocks.resolveManagedOAuthToken.mockRejectedValue(new Error('pool exhausted'))
+ await expect(searchSlackForViewer(params)).resolves.toEqual({ status: 'unavailable' })
+ })
+
+ it('absorbs a failure looking the credential up at all', async () => {
+ mocks.findViewerSlackCredentialId.mockRejectedValue(new Error('connection terminated'))
+ await expect(searchSlackForViewer(params)).resolves.toEqual({ status: 'unavailable' })
+ expect(mocks.searchSlack).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/sim/lib/slack-search/search.ts b/apps/sim/lib/slack-search/search.ts
new file mode 100644
index 00000000000..8ac815c7e4f
--- /dev/null
+++ b/apps/sim/lib/slack-search/search.ts
@@ -0,0 +1,118 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { getCredentialGroupProviderId } from '@/lib/credential-groups/providers'
+import { SLACK_MANAGED_USER_SCOPES } from '@/lib/credential-groups/slack-managed-user-scopes'
+import {
+ ManagedOAuthCredentialError,
+ resolveManagedOAuthToken,
+} from '@/lib/credentials/managed-oauth'
+import { searchSlack } from '@/lib/slack-search/client'
+import { findViewerSlackCredentialId } from '@/lib/slack-search/credentials'
+import type { SlackSearchResult } from '@/lib/slack-search/types'
+
+const logger = createLogger('SlackSearch')
+
+/**
+ * The scopes a federated search needs. A credential granted before search was
+ * added lacks them, and the mint refuses it as insufficiently scoped, which
+ * reaches the person as `needs_reauth` — the honest answer, since reconnecting
+ * is exactly what would fix it.
+ */
+const SLACK_SEARCH_SCOPES = SLACK_MANAGED_USER_SCOPES.filter((scope) =>
+ scope.startsWith('search:read.')
+)
+
+/**
+ * What a federated Slack leg produced. Failure is a state the surface shows
+ * rather than an exception it propagates: a search covers knowledge bases too,
+ * and Slack being unreachable must never cost the person those results.
+ */
+export type SlackSearchOutcome =
+ | { status: 'ok'; results: SlackSearchResult[] }
+ /** Nobody connected a Slack account for this person in this workspace. */
+ | { status: 'not_connected' }
+ /** They have one, but it must be authorized again before it can search. */
+ | { status: 'needs_reauth' }
+ /** Slack, or the credential behind it, could not answer this time. */
+ | { status: 'unavailable' }
+
+function outcomeForCredentialError(error: ManagedOAuthCredentialError): SlackSearchOutcome {
+ switch (error.code) {
+ case 'MANAGED_CREDENTIAL_NEEDS_REAUTH':
+ case 'MANAGED_CREDENTIAL_INSUFFICIENT_SCOPE':
+ case 'MANAGED_CREDENTIAL_REVOKED':
+ return { status: 'needs_reauth' }
+ case 'MANAGED_CREDENTIAL_NOT_FOUND':
+ return { status: 'not_connected' }
+ default:
+ return { status: 'unavailable' }
+ }
+}
+
+export interface SearchSlackForViewerParams {
+ workspaceId: string
+ /** The person asking. Slack is searched as them, under their own token. */
+ userId: string
+ query: string
+ limit?: number
+ signal?: AbortSignal
+}
+
+/**
+ * Searches Slack on behalf of the person asking, if they have connected it.
+ *
+ * Nothing is indexed and no permission is computed here: the search runs under
+ * the person's own Slack token, so Slack returns exactly the conversations
+ * they can already read and no more. That is why this needs no access scope,
+ * no ACL, and no crawl.
+ */
+export async function searchSlackForViewer(
+ params: SearchSlackForViewerParams
+): Promise {
+ let credentialId: string | null
+ try {
+ credentialId = await findViewerSlackCredentialId({
+ workspaceId: params.workspaceId,
+ userId: params.userId,
+ })
+ } catch (error) {
+ logger.error('Failed to look up a Slack search credential', {
+ error: getErrorMessage(error),
+ })
+ return { status: 'unavailable' }
+ }
+ if (!credentialId) return { status: 'not_connected' }
+
+ let accessToken: string
+ try {
+ const resolved = await resolveManagedOAuthToken({
+ credentialId,
+ workspaceId: params.workspaceId,
+ expectedProviderId: getCredentialGroupProviderId('slack'),
+ requiredScopes: [...SLACK_SEARCH_SCOPES],
+ })
+ accessToken = resolved.accessToken
+ } catch (error) {
+ if (error instanceof ManagedOAuthCredentialError) {
+ logger.info('Slack search credential unusable', { code: error.code })
+ return outcomeForCredentialError(error)
+ }
+ logger.error('Failed to resolve a Slack search credential', {
+ error: getErrorMessage(error),
+ })
+ return { status: 'unavailable' }
+ }
+
+ try {
+ const results = await searchSlack({
+ accessToken,
+ query: params.query,
+ ...(params.limit !== undefined ? { limit: params.limit } : {}),
+ ...(params.signal ? { signal: params.signal } : {}),
+ })
+ return { status: 'ok', results }
+ } catch (error) {
+ logger.warn('Slack search failed', { error: getErrorMessage(error) })
+ return { status: 'unavailable' }
+ }
+}
diff --git a/apps/sim/lib/slack-search/types.ts b/apps/sim/lib/slack-search/types.ts
new file mode 100644
index 00000000000..84c94ab8058
--- /dev/null
+++ b/apps/sim/lib/slack-search/types.ts
@@ -0,0 +1,31 @@
+/**
+ * One Slack message a federated search returned, normalized for Sim Search.
+ *
+ * Slack is searched at query time under the asking person's own token rather
+ * than crawled into a knowledge base, so a result carries everything needed to
+ * show and cite it without anything having been stored.
+ */
+export interface SlackSearchResult {
+ /** Stable identity of the message: its channel and timestamp. */
+ channelId: string
+ messageTs: string
+ channelName: string
+ authorName: string
+ /** The matched message, followed by the surrounding messages when they were requested. */
+ text: string
+ /** Slack's own permalink to the message. */
+ permalink: string
+ /** When the message was sent. Null when Slack returned a timestamp that will not parse. */
+ sentAt: Date | null
+ isAuthorBot: boolean
+}
+
+/** The conversation kinds a federated Slack search covers. */
+export const SLACK_SEARCH_CHANNEL_TYPES = [
+ 'public_channel',
+ 'private_channel',
+ 'mpim',
+ 'im',
+] as const
+
+export type SlackSearchChannelType = (typeof SLACK_SEARCH_CHANNEL_TYPES)[number]