Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 118 additions & 1 deletion apps/sim/lib/sim-search/live/providers.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from 'vitest'
import { readGitHub, searchGitHub } from '@/lib/sim-search/live/github'
import { searchCalendar, searchDrive } from '@/lib/sim-search/live/google'
import { defaultLiveSearchPolicy } from '@/lib/sim-search/live/policy-schema'
import { searchSlack } from '@/lib/sim-search/live/slack'
import type { NativeClient } from '@/lib/sim-search/live/types'

Expand Down Expand Up @@ -151,7 +152,7 @@ describe('native search endpoints', () => {
query: 'launch',
channel_types: ['public_channel', 'im'],
include_archived_channels: true,
content_types: ['messages'],
content_types: ['messages', 'channels'],
include_context_messages: true,
limit: 20,
term_clauses: ['launch OR release'],
Expand Down Expand Up @@ -384,3 +385,119 @@ describe('Slack RTS pages', () => {
).rejects.toMatchObject({ status: 'rate_limited' })
})
})

describe('Slack channels', () => {
const scopes = ['search:read.public', 'search:read.private']
const channel = (id: string, name: string, archived = false) => ({
name,
is_archived: archived,
permalink: `https://team.slack.com/archives/${id}`,
})
const rts = (channels: unknown[], messages: unknown[] = []) => ({
ok: true,
results: { messages, channels },
})

it('names the channels a query matches that the source settings let it search', async () => {
const api = client()
api.json.mockResolvedValue(
rts([
channel('C1', 'announcements'),
channel('G2', 'announce-leads'),
channel('C3', 'announce-old', true),
])
)
const result = await searchSlack(api, {
...input,
scopes,
policy: { ...defaultLiveSearchPolicy('slack'), excluded: ['G2'], includeArchived: false },
native: { provider: 'slack', query: 'announcements' },
})
expect(result.message).toContain('Channels matching the query: #announcements (in:<#C1>).')
expect(result.message).not.toContain('announce-leads')
expect(result.message).not.toContain('announce-old')
})

it('says a filtered channel exists when none of its messages match', async () => {
const api = client()
api.json
.mockResolvedValueOnce(rts([]))
.mockResolvedValueOnce(rts([channel('C9', 'launch-team'), channel('C8', 'launch-team-ops')]))
const result = await searchSlack(api, {
...input,
query: '',
scopes,
filters: { startDate: '2026-10-30T00:00:00Z' },
native: { provider: 'slack', query: '', modifiers: 'in:#Launch-Team' },
})
expect(result.documents).toEqual([])
expect(result.message).toContain(
'#launch-team (in:<#C9>) exists; none of its messages matched this query and date window.'
)
})

it('says no channel has the filtered name and names the similar ones', async () => {
const api = client()
api.json
.mockResolvedValueOnce(rts([]))
.mockResolvedValueOnce(rts([channel('C7', 'company-announcements')]))
const result = await searchSlack(api, {
...input,
query: '',
scopes,
filters: { startDate: '2026-10-30T00:00:00Z' },
native: { provider: 'slack', query: '', modifiers: 'in:#announcements' },
})
expect(result.message).toContain('No channel named #announcements is visible to this account.')
expect(result.message).toContain(
'Channels with similar names: #company-announcements (in:<#C7>).'
)
})

it('does not call a channel missing while the lookup has more channels to return', async () => {
const api = client()
api.json.mockResolvedValueOnce(rts([])).mockResolvedValueOnce({
...rts([channel('C7', 'company-announcements')]),
response_metadata: { next_cursor: 'more' },
})
const result = await searchSlack(api, {
...input,
query: '',
scopes,
filters: { startDate: '2026-10-30T00:00:00Z' },
native: { provider: 'slack', query: '', modifiers: 'in:#announcements' },
})
expect(api.json.mock.calls[1]?.[1]?.body).toMatchObject({ limit: 20 })
expect(result.message).not.toContain('No channel named')
expect(result.message).toContain('Channels with similar names: #company-announcements')
})

it('says nothing about the filtered channel while Slack has another page of results', async () => {
const api = client()
api.json.mockResolvedValueOnce({ ...rts([]), response_metadata: { next_cursor: 'page-2' } })
const result = await searchSlack(api, {
...input,
query: '',
scopes,
filters: { startDate: '2026-10-30T00:00:00Z' },
native: { provider: 'slack', query: '', modifiers: 'in:#announcements' },
})
expect(result.message).not.toContain('#announcements')
})

it('keeps the empty search when the channel lookup fails', async () => {
const api = client()
api.json
.mockResolvedValueOnce(rts([]))
.mockResolvedValueOnce({ ok: false, error: 'ratelimited' })
const result = await searchSlack(api, {
...input,
query: '',
scopes,
filters: { startDate: '2026-10-30T00:00:00Z' },
native: { provider: 'slack', query: '', modifiers: 'in:#announcements' },
})
expect(result.documents).toEqual([])
expect(result.message).not.toContain('#announcements')
})
})
100 changes: 95 additions & 5 deletions apps/sim/lib/sim-search/live/slack.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { dateSortDirection, nativeDateBounds, nativeText } from '@/lib/sim-search/live/dates'
import { array, NativeSearchError, object, string } from '@/lib/sim-search/live/http'
import { joinMessages } from '@/lib/sim-search/live/pages'
import { permitsResources } from '@/lib/sim-search/live/policy'
import {
slackConversationName,
slackConversationUrl,
Expand Down Expand Up @@ -36,6 +37,82 @@ function slackResult(value: unknown) {
return data
}

/** Channels a search names in its status, so the client can filter on one with `in:<#ID>`. */
const MAX_LISTED_CHANNELS = 5

interface SlackChannel {
id: string
name: string
archived: boolean
}

/**
* Channels from an RTS response that the account's source settings let it search. The ID comes
* from the permalink, as RTS channel results carry no ID field.
*/
function searchableChannels(data: Record<string, unknown>, input: NativeSearchInput) {
return array(object(data.results).channels).flatMap((channel): SlackChannel[] => {
const id = string(channel.permalink).match(/\/archives\/([CG][A-Z0-9]+)$/)?.[1]
const name = string(channel.name)
const archived = channel.is_archived === true
if (!id || !name) return []
if (input.policy && !permitsResources(input.policy, [id])) return []
if (archived && input.policy?.includeArchived === false) return []
return [{ id, name, archived }]
})
}

const channelLabel = ({ id, name, archived }: SlackChannel) =>
`#${name} (in:<#${id}>${archived ? ', archived' : ''})`

/** The channel name an `in:#name` filter in the query or modifiers names, if any. */
function filteredChannelName(input: NativeSearchInput): string | undefined {
return [nativeText(input), input.native?.modifiers]
.join(' ')
.match(/(?:^|\s)in:#([\w.-]+)/i)?.[1]
?.toLowerCase()
}

/**
* Whether the channel a query filtered to exists, said when the query found nothing, since RTS
* returns the same empty page for a quiet channel and a name that matches no channel. A lookup
* that fails says nothing rather than failing the search.
*/
async function channelFilterNote(
client: NativeClient,
input: NativeSearchInput,
name: string,
channelTypes: string[]
): Promise<string | undefined> {
const data = await client
.json('/api/assistant.search.context', {
body: {
query: name,
channel_types: channelTypes,
include_archived_channels: input.policy?.includeArchived ?? true,
content_types: ['channels'],
limit: SLACK_PAGE_SIZE,
},
})
.then(slackResult)
.catch((error: unknown) => {
if (input.signal?.aborted) throw error
return undefined
})
if (!data) return undefined
const channels = searchableChannels(data, input)
const named = channels.find((channel) => channel.name.toLowerCase() === name)
if (named)
return `${channelLabel(named)} exists; none of its messages matched this query and date window.`
/** A lookup with another page has not ruled the name out, so it only names what it found. */
const complete = !string(object(data.response_metadata).next_cursor)
const similar = channels.slice(0, MAX_LISTED_CHANNELS).map(channelLabel).join(', ')
return joinMessages([
complete ? `No channel named #${name} is visible to this account.` : '',
similar ? `Channels with similar names: ${similar}.` : '',
])
}

/** Results one Real-time Search call returns at most. */
const SLACK_PAGE_SIZE = 20
/**
Expand Down Expand Up @@ -117,6 +194,8 @@ async function searchSlackPage(
.filter(Boolean)
.join(' ')
const modifiers = [input.native?.modifiers, dateModifiers].filter(Boolean).join(' ')
/** Channels match on name, topic and purpose, so only a query with text lists them, once. */
const listChannels = Boolean(nativeText(input)) && !cursor
const data = slackResult(
await client.json('/api/assistant.search.context', {
body: {
Expand All @@ -125,9 +204,11 @@ async function searchSlackPage(
.join(' '),
channel_types: channels,
include_archived_channels: input.policy?.includeArchived ?? true,
content_types: input.scopes.includes('search:read.files')
? ['messages', 'files']
: ['messages'],
content_types: [
'messages',
...(input.scopes.includes('search:read.files') ? ['files'] : []),
...(listChannels ? ['channels'] : []),
],
include_context_messages: true,
limit: Math.min(limit, SLACK_PAGE_SIZE),
...(dates.start ? { after: Math.floor(Date.parse(dates.start) / 1000) - 1 } : {}),
Expand Down Expand Up @@ -216,10 +297,19 @@ async function searchSlackPage(
: {}),
})
}
const listed = listChannels ? searchableChannels(data, input).slice(0, MAX_LISTED_CHANNELS) : []
const nextCursor = string(object(data.response_metadata).next_cursor) || undefined
/** Only a search with no result on any page can say what its channel filter matched. */
const filtered =
!documents.length && !cursor && !nextCursor ? filteredChannelName(input) : undefined
return {
documents,
nextCursor: string(object(data.response_metadata).next_cursor) || undefined,
message: `Slack RTS searches ${channels.join(', ')} with your user token. Semantic matching depends on your Slack plan; date ordering uses keyword retrieval. Message timestamps are posting dates; edits may not be reflected in date filtering.`,
nextCursor,
message: joinMessages([
`Slack RTS searches ${channels.join(', ')} with your user token. Semantic matching depends on your Slack plan; date ordering uses keyword retrieval. Message timestamps are posting dates; edits may not be reflected in date filtering.`,
listed.length ? `Channels matching the query: ${listed.map(channelLabel).join(', ')}.` : '',
filtered ? await channelFilterNote(client, input, filtered, channels) : '',
]),
}
}

Expand Down
Loading