Skip to content

Commit 8ee3abf

Browse files
authored
feat(knowledge): crawl Slack per member on Sim Search through each person's own Slack user token (#7421)
* feat(knowledge): crawl Slack per member on Sim Search through each person's own Slack user token * fix(connectors): keep the newest messages when a chat transcript reaches the size limit * fix(slack): fold the channel header into the content hash so a rename or topic edit re-indexes
1 parent bce8571 commit 8ee3abf

12 files changed

Lines changed: 691 additions & 132 deletions

File tree

apps/sim/connectors/google-chat/google-chat.ts

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -328,24 +328,27 @@ function formatSpaceContent(
328328
space: Space,
329329
messages: ChatMessage[]
330330
): { content: string; messageCount: number } {
331-
const parts = new BoundedLines()
332-
parts.push(`Space: ${spaceTitle(space)}`)
331+
/** The newest messages survive when the window does not fit; the header always does. */
332+
const parts = new BoundedLines(CONNECTOR_TEXT_DOCUMENT_MAX_BYTES, 'last')
333+
parts.pin(`Space: ${spaceTitle(space)}`)
333334
const description = space.spaceDetails?.description?.trim()
334-
if (description) parts.push(`Description: ${description}`)
335+
if (description) parts.pin(`Description: ${description}`)
335336
const guidelines = space.spaceDetails?.guidelines?.trim()
336-
if (guidelines) parts.push(`Guidelines: ${guidelines}`)
337+
if (guidelines) parts.pin(`Guidelines: ${guidelines}`)
337338

338-
let messageCount = 0
339+
let headed = false
339340
for (const message of messages) {
340341
const text = message.text?.trim() || message.fallbackText?.trim()
341342
if (!text) continue
342-
if (messageCount === 0) parts.push('', '--- Messages ---')
343+
if (!headed) {
344+
parts.pin('', '--- Messages ---')
345+
headed = true
346+
}
343347
const timestamp = message.createTime ?? ''
344-
if (!parts.push(`[${timestamp}] ${senderLabel(message.sender)}: ${text}`)) break
345-
messageCount += 1
348+
parts.push(`[${timestamp}] ${senderLabel(message.sender)}: ${text}`)
346349
}
347350

348-
return { content: parts.join(), messageCount }
351+
return { content: parts.join(), messageCount: parts.count }
349352
}
350353

351354
export const googleChatConnector: ConnectorConfig = {

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: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
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+
/** The channel `conversations.info` returns; per-test overridable. */
42+
let infoChannel: Record<string, unknown> = GENERAL
43+
44+
beforeEach(() => {
45+
requestedUrls.length = 0
46+
listedChannels = [GENERAL, PLATFORM]
47+
listNextCursor = ''
48+
history = MESSAGES
49+
channelMissing = false
50+
infoChannel = GENERAL
51+
fetchMock.mockReset()
52+
fetchMock.mockImplementation(async (input) => {
53+
const url = new URL(String(input))
54+
requestedUrls.push(`${url.pathname}?${url.searchParams.toString()}`)
55+
switch (url.pathname) {
56+
case '/api/auth.test':
57+
return jsonResponse({ ok: true, team_id: 'T0TEAM' })
58+
case '/api/conversations.list':
59+
return jsonResponse({
60+
ok: true,
61+
channels: listedChannels,
62+
response_metadata: { next_cursor: listNextCursor },
63+
})
64+
case '/api/conversations.info':
65+
return channelMissing
66+
? jsonResponse({ ok: false, error: 'channel_not_found' })
67+
: jsonResponse({ ok: true, channel: infoChannel })
68+
case '/api/conversations.history':
69+
return jsonResponse({ ok: true, messages: history, response_metadata: {} })
70+
case '/api/users.info': {
71+
const id = url.searchParams.get('user')
72+
return jsonResponse({ ok: true, user: { id, name: id, real_name: `Person ${id}` } })
73+
}
74+
default:
75+
return jsonResponse({ ok: false, error: 'unknown_method' })
76+
}
77+
})
78+
vi.stubGlobal('fetch', fetchMock)
79+
})
80+
81+
afterEach(() => {
82+
vi.unstubAllGlobals()
83+
})
84+
85+
const requested = (method: string) => requestedUrls.filter((url) => url.includes(`/${method}?`))
86+
87+
describe('slack connector meta', () => {
88+
it('crawls per member with the channel selection as the only listing cap', () => {
89+
expect(slackConnectorMeta.permissionScopedListing).toEqual({ capFieldIds: ['channel'] })
90+
})
91+
})
92+
93+
describe('listDocuments', () => {
94+
it('lists configured channels as deferred stubs without reading their history', async () => {
95+
const syncContext: Record<string, unknown> = { syncRunId: 'run-1' }
96+
const result = await slackConnector.listDocuments(
97+
'token',
98+
{ channel: ['C0GENERAL'] },
99+
undefined,
100+
syncContext
101+
)
102+
103+
expect(result.hasMore).toBe(false)
104+
expect(result.documents).toEqual([
105+
expect.objectContaining({
106+
externalId: 'C0GENERAL',
107+
title: '#general',
108+
content: '',
109+
contentDeferred: true,
110+
estimatedBytes: CONNECTOR_TEXT_DOCUMENT_MAX_BYTES,
111+
contentHash: 'slack-listing:C0GENERAL:run-1',
112+
sourceUrl: 'https://app.slack.com/client/T0TEAM/C0GENERAL',
113+
metadata: expect.objectContaining({ channelName: 'general' }),
114+
}),
115+
])
116+
expect(requested('conversations.history')).toHaveLength(0)
117+
})
118+
119+
it('lists every readable channel when none is configured, paging through the cursor', async () => {
120+
listNextCursor = 'page-2'
121+
const syncContext: Record<string, unknown> = {
122+
syncRunId: 'run-1',
123+
...PER_MEMBER_LISTING_CONTEXT,
124+
}
125+
const first = await slackConnector.listDocuments(
126+
'token',
127+
{ channel: 0 },
128+
undefined,
129+
syncContext
130+
)
131+
132+
expect(first.documents.map((doc) => doc.externalId)).toEqual(['C0GENERAL', 'G0PLATFORM'])
133+
expect(first).toMatchObject({ hasMore: true, nextCursor: 'page-2' })
134+
expect(requested('conversations.list')[0]).toContain('types=public_channel%2Cprivate_channel')
135+
expect(requested('conversations.list')[0]).toContain('exclude_archived=true')
136+
137+
listNextCursor = ''
138+
listedChannels = []
139+
const second = await slackConnector.listDocuments(
140+
'token',
141+
{ channel: 0 },
142+
'page-2',
143+
syncContext
144+
)
145+
expect(second).toEqual({ documents: [], nextCursor: undefined, hasMore: false })
146+
expect(requested('conversations.list')[1]).toContain('cursor=page-2')
147+
})
148+
149+
it('gives every member of one run the same stub for a channel', async () => {
150+
const ada = await slackConnector.listDocuments('ada', {}, undefined, { syncRunId: 'run-7' })
151+
const bob = await slackConnector.listDocuments('bob', {}, undefined, { syncRunId: 'run-7' })
152+
expect(ada.documents[0].contentHash).toBe(bob.documents[0].contentHash)
153+
})
154+
155+
it('changes the stub between runs so each run re-reads the channel', async () => {
156+
const first = await slackConnector.listDocuments('token', {}, undefined, {})
157+
const second = await slackConnector.listDocuments('token', {}, undefined, {})
158+
expect(first.documents[0].contentHash).not.toBe(second.documents[0].contentHash)
159+
})
160+
})
161+
162+
describe('getDocument', () => {
163+
it('builds the transcript under a header with the real content hash', async () => {
164+
const doc = await slackConnector.getDocument('token', {}, 'C0GENERAL', {})
165+
166+
expect(doc).toMatchObject({
167+
externalId: 'C0GENERAL',
168+
title: '#general',
169+
contentHash: expect.stringMatching(
170+
/^slack-v3:C0GENERAL:[0-9a-f]{16}:1700000000\.000100:1700000200\.000100:3:noedit:noreply:0$/
171+
),
172+
metadata: expect.objectContaining({ channelName: 'general', messageCount: 2 }),
173+
})
174+
expect(doc?.content).toBe(
175+
[
176+
'Channel: #general',
177+
'Topic: Company-wide announcements',
178+
'',
179+
'[2023-11-14T22:15:00.000Z] Person U1: Morning',
180+
'[2023-11-14T22:16:40.000Z] Person U2: Shipping today',
181+
].join('\n')
182+
)
183+
})
184+
185+
it('moves the hash when the header changes without any message changing', async () => {
186+
const before = await slackConnector.getDocument('token', {}, 'C0GENERAL', {})
187+
188+
infoChannel = { ...GENERAL, name: 'general-renamed' }
189+
const renamed = await slackConnector.getDocument('token', {}, 'C0GENERAL', {})
190+
expect(renamed?.contentHash).not.toBe(before?.contentHash)
191+
192+
infoChannel = { ...GENERAL, topic: { value: 'A new topic' } }
193+
const retopiced = await slackConnector.getDocument('token', {}, 'C0GENERAL', {})
194+
expect(retopiced?.contentHash).not.toBe(before?.contentHash)
195+
196+
infoChannel = GENERAL
197+
const again = await slackConnector.getDocument('token', {}, 'C0GENERAL', {})
198+
expect(again?.contentHash).toBe(before?.contentHash)
199+
})
200+
201+
it('keeps a channel with no messages as a live document', async () => {
202+
history = []
203+
const doc = await slackConnector.getDocument('token', {}, 'C0GENERAL', {})
204+
expect(doc?.content).toBe('Channel: #general\nTopic: Company-wide announcements\n')
205+
expect(doc?.metadata?.messageCount).toBe(0)
206+
})
207+
208+
it('returns null only for a channel Slack no longer knows', async () => {
209+
channelMissing = true
210+
await expect(slackConnector.getDocument('token', {}, 'C0GONE', {})).resolves.toBeNull()
211+
})
212+
})

0 commit comments

Comments
 (0)