diff --git a/apps/sim/app/api/knowledge/sim-search/slack/route.ts b/apps/sim/app/api/knowledge/sim-search/slack/route.ts new file mode 100644 index 00000000000..b392ab84ede --- /dev/null +++ b/apps/sim/app/api/knowledge/sim-search/slack/route.ts @@ -0,0 +1,27 @@ +import { searchSimSearchSlackContract } from '@/lib/api/contracts/knowledge' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalKnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { searchSimSearchSlack } from '@/lib/knowledge/application/sim-search' + +export const POST = defineInternalJsonRoute({ + contract: searchSimSearchSlackContract, + auth: internalSessionAuth, + operation: knowledgeOperations.simSearchFederated, + rateLimit: internalRateLimits.none({ + reason: + "Slack's own per-user limit bounds this, and a failed call degrades to no Slack results", + }), + errorPolicy: internalKnowledgeErrorPolicies.search, + mapInput: ({ body }) => ({ + workspaceId: body.workspaceId, + query: body.query, + limit: body.limit, + }), + useCase: searchSimSearchSlack, + present: (result) => ({ success: true as const, data: result }), +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx index 5c9f0a1b469..9adb1756be2 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx @@ -9,6 +9,7 @@ import type { WorkspaceKnowledgeSearchResult } from '@/lib/api/contracts/knowled import { matchSnippet } from '@/lib/knowledge/search/snippet' import { connectorDisplayName } from '@/lib/sim-search/connectors' import { searchedKnowledgeBases } from '@/lib/sim-search/knowledge-bases' +import { SlackSearchResults } from '@/app/workspace/[workspaceId]/home/components/knowledge-search-results/slack-search-results' import { highlightTerms, SOURCE_ROW_CLASSES, @@ -225,28 +226,28 @@ export function KnowledgeSearchResults({ }, [documents, filtersActive, filters.source, filters.updated]) const failure = basesError ?? error - if (failure) { - return

{failure.message}

- } - if (!basesPending && knowledgeBaseIds.length === 0) { - return ( -

- Nothing to search yet. Clear the query and connect a source to index what you can open. -

- ) - } - /** Kept results belong to the previous query; a new query shows its own state. */ - if (isPending || isPlaceholderData || (isFetching && !results)) { - return

Searching…

- } - const indexingNote = indexing.length > 0 ? `Still indexing ${indexing.join(', ')}; results grow as documents land.` : null - return ( -
+ /** + * The indexed half of the page, in whatever state it is in. It is a branch + * rather than an early return because a federated source is searched even + * where there is nothing indexed at all — a workspace whose only source is + * Slack has no knowledge base, and its failures are not Slack's. + */ + const knowledgeSection = failure ? ( +

{failure.message}

+ ) : !basesPending && knowledgeBaseIds.length === 0 ? ( +

+ No indexed sources yet. Connect one to index what you can open. +

+ ) : /** Kept results belong to the previous query; a new query shows its own state. */ + isPending || isPlaceholderData || (isFetching && !results) ? ( +

Searching…

+ ) : ( + <>
@@ -298,7 +299,7 @@ export function KnowledgeSearchResults({ : 'No documents match these filters.'}

) : ( -
+
{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]