Skip to content

Commit 9fc991a

Browse files
committed
feat(knowledge): crawl Slack per member on Sim Search through each person's own Slack user token
1 parent c77b070 commit 9fc991a

9 files changed

Lines changed: 539 additions & 106 deletions

File tree

apps/sim/connectors/permission-scoped-listing.test.ts

Lines changed: 25 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,29 @@
33
*/
44
import { describe, expect, it } from 'vitest'
55
import { getManagedOAuthConnectorPolicy } from '@/lib/auth/connectors/managed-oauth'
6+
import { getCredentialGroupProviderAdapter } from '@/lib/credential-groups/provider-registry'
67
import {
8+
type CredentialGroupProvider,
9+
getCredentialGroupProviderFromProviderId,
710
getCredentialGroupProviderService,
8-
getCredentialGroupStandardOAuthProviderFromProviderId,
11+
isCredentialGroupStandardOAuthProvider,
912
} from '@/lib/credential-groups/providers'
13+
import { SLACK_MANAGED_USER_SCOPES } from '@/lib/credential-groups/slack-managed-user-scopes'
1014
import { CONNECTOR_META_REGISTRY } from '@/connectors/registry'
1115

16+
/**
17+
* The scopes an option of the provider requests from every member: the
18+
* provider's service scopes plus its managed policy's additions for a standard
19+
* OAuth provider, and the fixed user-token policy for Slack.
20+
*/
21+
function optionScopesFor(provider: CredentialGroupProvider): string[] {
22+
if (!isCredentialGroupStandardOAuthProvider(provider)) return [...SLACK_MANAGED_USER_SCOPES]
23+
const service = getCredentialGroupProviderService(provider)
24+
const policy = getManagedOAuthConnectorPolicy(service.providerId)
25+
expect(policy).toBeDefined()
26+
return [...new Set([...service.scopes, ...(policy?.additionalScopes ?? [])])]
27+
}
28+
1229
const permissionScoped = Object.values(CONNECTOR_META_REGISTRY).filter(
1330
(meta) => meta.permissionScopedListing !== undefined
1431
)
@@ -50,32 +67,22 @@ describe('permission-scoped connector listings', () => {
5067
'outlook',
5168
'salesforce',
5269
'sharepoint',
70+
'slack',
5371
'zoom',
5472
])
5573
})
5674

5775
it.each(permissionScoped.map((meta) => [meta.id, meta] as const))(
58-
'%s authenticates through a managed OAuth provider whose option scopes cover its read scopes',
76+
'%s authenticates through a Credential Group provider whose option scopes cover its read scopes',
5977
(_id, meta) => {
6078
expect(meta.auth.mode).toBe('oauth')
6179
if (meta.auth.mode !== 'oauth') return
6280

63-
const policy = getManagedOAuthConnectorPolicy(meta.auth.provider)
64-
expect(policy).toBeDefined()
65-
if (!policy) return
66-
67-
const groupProvider = getCredentialGroupStandardOAuthProviderFromProviderId(
68-
meta.auth.provider
69-
)
70-
expect(groupProvider).toBeDefined()
71-
72-
const optionScopes = [
73-
...new Set([
74-
...getCredentialGroupProviderService(groupProvider).scopes,
75-
...policy.additionalScopes,
76-
]),
77-
]
78-
expect(policy.hasRequiredScopes(optionScopes, meta.auth.requiredScopes ?? [])).toBe(true)
81+
const provider = getCredentialGroupProviderFromProviderId(meta.auth.provider)
82+
const adapter = getCredentialGroupProviderAdapter(provider)
83+
expect(
84+
adapter.hasRequiredScopes(optionScopesFor(provider), meta.auth.requiredScopes ?? [])
85+
).toBe(true)
7986
}
8087
)
8188

apps/sim/connectors/slack/meta.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,17 @@ export const slackConnectorMeta: ConnectorMeta = {
2222
],
2323
},
2424

25+
/**
26+
* `conversations.list` under a person's own token returns the public
27+
* channels of their workspace and the private channels they belong to,
28+
* exactly what they may read, so one member's crawl is their access. The
29+
* channel selection is a cap: it would hide part of a member's corpus, and
30+
* the per-member crawl indexes every channel the member can see instead.
31+
* `maxMessages` bounds each channel document's window, not which channels
32+
* are listed, so it is not a cap.
33+
*/
34+
permissionScopedListing: { capFieldIds: ['channel'] },
35+
2536
configFields: [
2637
{
2738
id: 'channelSelector',
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5+
import { slackConnectorMeta } from '@/connectors/slack/meta'
6+
import { slackConnector } from '@/connectors/slack/slack'
7+
import { CONNECTOR_TEXT_DOCUMENT_MAX_BYTES, PER_MEMBER_LISTING_CONTEXT } from '@/connectors/utils'
8+
9+
const GENERAL = {
10+
id: 'C0GENERAL',
11+
name: 'general',
12+
topic: { value: 'Company-wide announcements' },
13+
purpose: { value: '' },
14+
}
15+
const PLATFORM = { id: 'G0PLATFORM', name: 'platform', topic: { value: '' } }
16+
17+
const MESSAGES = [
18+
{ type: 'message', user: 'U2', text: 'Shipping today', ts: '1700000200.000100' },
19+
{ type: 'message', user: 'U1', text: 'Morning', ts: '1700000100.000100' },
20+
{ type: 'message', subtype: 'channel_join', user: 'U1', text: 'joined', ts: '1700000000.000100' },
21+
]
22+
23+
function jsonResponse(body: unknown): Response {
24+
return new Response(JSON.stringify(body), {
25+
status: 200,
26+
headers: { 'Content-Type': 'application/json' },
27+
})
28+
}
29+
30+
const requestedUrls: string[] = []
31+
const fetchMock = vi.fn<(input: string | URL | Request, init?: RequestInit) => Promise<Response>>()
32+
33+
/** Channels returned by `conversations.list`; per-test overridable. */
34+
let listedChannels: Record<string, unknown>[] = [GENERAL, PLATFORM]
35+
/** `next_cursor` returned by `conversations.list`; per-test overridable. */
36+
let listNextCursor = ''
37+
/** Messages returned by `conversations.history`; per-test overridable. */
38+
let history: Record<string, unknown>[] = MESSAGES
39+
/** Whether `conversations.info` reports the channel as missing; per-test overridable. */
40+
let channelMissing = false
41+
42+
beforeEach(() => {
43+
requestedUrls.length = 0
44+
listedChannels = [GENERAL, PLATFORM]
45+
listNextCursor = ''
46+
history = MESSAGES
47+
channelMissing = false
48+
fetchMock.mockReset()
49+
fetchMock.mockImplementation(async (input) => {
50+
const url = new URL(String(input))
51+
requestedUrls.push(`${url.pathname}?${url.searchParams.toString()}`)
52+
switch (url.pathname) {
53+
case '/api/auth.test':
54+
return jsonResponse({ ok: true, team_id: 'T0TEAM' })
55+
case '/api/conversations.list':
56+
return jsonResponse({
57+
ok: true,
58+
channels: listedChannels,
59+
response_metadata: { next_cursor: listNextCursor },
60+
})
61+
case '/api/conversations.info':
62+
return channelMissing
63+
? jsonResponse({ ok: false, error: 'channel_not_found' })
64+
: jsonResponse({ ok: true, channel: GENERAL })
65+
case '/api/conversations.history':
66+
return jsonResponse({ ok: true, messages: history, response_metadata: {} })
67+
case '/api/users.info': {
68+
const id = url.searchParams.get('user')
69+
return jsonResponse({ ok: true, user: { id, name: id, real_name: `Person ${id}` } })
70+
}
71+
default:
72+
return jsonResponse({ ok: false, error: 'unknown_method' })
73+
}
74+
})
75+
vi.stubGlobal('fetch', fetchMock)
76+
})
77+
78+
afterEach(() => {
79+
vi.unstubAllGlobals()
80+
})
81+
82+
const requested = (method: string) => requestedUrls.filter((url) => url.includes(`/${method}?`))
83+
84+
describe('slack connector meta', () => {
85+
it('crawls per member with the channel selection as the only listing cap', () => {
86+
expect(slackConnectorMeta.permissionScopedListing).toEqual({ capFieldIds: ['channel'] })
87+
})
88+
})
89+
90+
describe('listDocuments', () => {
91+
it('lists configured channels as deferred stubs without reading their history', async () => {
92+
const syncContext: Record<string, unknown> = { syncRunId: 'run-1' }
93+
const result = await slackConnector.listDocuments(
94+
'token',
95+
{ channel: ['C0GENERAL'] },
96+
undefined,
97+
syncContext
98+
)
99+
100+
expect(result.hasMore).toBe(false)
101+
expect(result.documents).toEqual([
102+
expect.objectContaining({
103+
externalId: 'C0GENERAL',
104+
title: '#general',
105+
content: '',
106+
contentDeferred: true,
107+
estimatedBytes: CONNECTOR_TEXT_DOCUMENT_MAX_BYTES,
108+
contentHash: 'slack-listing:C0GENERAL:run-1',
109+
sourceUrl: 'https://app.slack.com/client/T0TEAM/C0GENERAL',
110+
metadata: expect.objectContaining({ channelName: 'general' }),
111+
}),
112+
])
113+
expect(requested('conversations.history')).toHaveLength(0)
114+
})
115+
116+
it('lists every readable channel when none is configured, paging through the cursor', async () => {
117+
listNextCursor = 'page-2'
118+
const syncContext: Record<string, unknown> = {
119+
syncRunId: 'run-1',
120+
...PER_MEMBER_LISTING_CONTEXT,
121+
}
122+
const first = await slackConnector.listDocuments(
123+
'token',
124+
{ channel: 0 },
125+
undefined,
126+
syncContext
127+
)
128+
129+
expect(first.documents.map((doc) => doc.externalId)).toEqual(['C0GENERAL', 'G0PLATFORM'])
130+
expect(first).toMatchObject({ hasMore: true, nextCursor: 'page-2' })
131+
expect(requested('conversations.list')[0]).toContain('types=public_channel%2Cprivate_channel')
132+
expect(requested('conversations.list')[0]).toContain('exclude_archived=true')
133+
134+
listNextCursor = ''
135+
listedChannels = []
136+
const second = await slackConnector.listDocuments(
137+
'token',
138+
{ channel: 0 },
139+
'page-2',
140+
syncContext
141+
)
142+
expect(second).toEqual({ documents: [], nextCursor: undefined, hasMore: false })
143+
expect(requested('conversations.list')[1]).toContain('cursor=page-2')
144+
})
145+
146+
it('gives every member of one run the same stub for a channel', async () => {
147+
const ada = await slackConnector.listDocuments('ada', {}, undefined, { syncRunId: 'run-7' })
148+
const bob = await slackConnector.listDocuments('bob', {}, undefined, { syncRunId: 'run-7' })
149+
expect(ada.documents[0].contentHash).toBe(bob.documents[0].contentHash)
150+
})
151+
152+
it('changes the stub between runs so each run re-reads the channel', async () => {
153+
const first = await slackConnector.listDocuments('token', {}, undefined, {})
154+
const second = await slackConnector.listDocuments('token', {}, undefined, {})
155+
expect(first.documents[0].contentHash).not.toBe(second.documents[0].contentHash)
156+
})
157+
})
158+
159+
describe('getDocument', () => {
160+
it('builds the transcript under a header with the real content hash', async () => {
161+
const doc = await slackConnector.getDocument('token', {}, 'C0GENERAL', {})
162+
163+
expect(doc).toMatchObject({
164+
externalId: 'C0GENERAL',
165+
title: '#general',
166+
contentHash: 'slack-v3:C0GENERAL:1700000000.000100:1700000200.000100:3:noedit:noreply:0',
167+
metadata: expect.objectContaining({ channelName: 'general', messageCount: 2 }),
168+
})
169+
expect(doc?.content).toBe(
170+
[
171+
'Channel: #general',
172+
'Topic: Company-wide announcements',
173+
'',
174+
'[2023-11-14T22:15:00.000Z] Person U1: Morning',
175+
'[2023-11-14T22:16:40.000Z] Person U2: Shipping today',
176+
].join('\n')
177+
)
178+
})
179+
180+
it('keeps a channel with no messages as a live document', async () => {
181+
history = []
182+
const doc = await slackConnector.getDocument('token', {}, 'C0GENERAL', {})
183+
expect(doc?.content).toBe('Channel: #general\nTopic: Company-wide announcements\n')
184+
expect(doc?.metadata?.messageCount).toBe(0)
185+
})
186+
187+
it('returns null only for a channel Slack no longer knows', async () => {
188+
channelMissing = true
189+
await expect(slackConnector.getDocument('token', {}, 'C0GONE', {})).resolves.toBeNull()
190+
})
191+
})

0 commit comments

Comments
 (0)