From db07c5bcec0a32c74df88e40a9d6e657e5a196c6 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 13 Nov 2026 12:52:44 -0800 Subject: [PATCH 1/3] improvement(search): name matching Slack channels and say whether a filtered channel exists --- .../sim/lib/sim-search/live/providers.test.ts | 88 +++++++++++++++++- apps/sim/lib/sim-search/live/slack.ts | 92 ++++++++++++++++++- 2 files changed, 175 insertions(+), 5 deletions(-) diff --git a/apps/sim/lib/sim-search/live/providers.test.ts b/apps/sim/lib/sim-search/live/providers.test.ts index e6299d5aa30..41987c5881b 100644 --- a/apps/sim/lib/sim-search/live/providers.test.ts +++ b/apps/sim/lib/sim-search/live/providers.test.ts @@ -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' @@ -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'], @@ -384,3 +385,88 @@ 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('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') + }) +}) diff --git a/apps/sim/lib/sim-search/live/slack.ts b/apps/sim/lib/sim-search/live/slack.ts index 39701e4c970..0300d3674e0 100644 --- a/apps/sim/lib/sim-search/live/slack.ts +++ b/apps/sim/lib/sim-search/live/slack.ts @@ -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, @@ -36,6 +37,79 @@ 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, 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 { + 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: MAX_LISTED_CHANNELS, + }, + }) + .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.` + return joinMessages([ + `No channel named #${name} is visible to this account.`, + channels.length ? `Channels with similar names: ${channels.map(channelLabel).join(', ')}.` : '', + ]) +} + /** Results one Real-time Search call returns at most. */ const SLACK_PAGE_SIZE = 20 /** @@ -117,6 +191,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: { @@ -125,9 +201,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 } : {}), @@ -216,10 +294,16 @@ async function searchSlackPage( : {}), }) } + const listed = listChannels ? searchableChannels(data, input).slice(0, MAX_LISTED_CHANNELS) : [] + const filtered = !documents.length && !cursor ? 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.`, + 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) : '', + ]), } } From f6537860b0271de7d6c0b3b1e5821243ab31e73c Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 13 Nov 2026 12:56:16 -0800 Subject: [PATCH 2/3] chore(search): format the Slack channel tests --- apps/sim/lib/sim-search/live/providers.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/sim/lib/sim-search/live/providers.test.ts b/apps/sim/lib/sim-search/live/providers.test.ts index 41987c5881b..162edb1b4cc 100644 --- a/apps/sim/lib/sim-search/live/providers.test.ts +++ b/apps/sim/lib/sim-search/live/providers.test.ts @@ -422,9 +422,7 @@ describe('Slack channels', () => { const api = client() api.json .mockResolvedValueOnce(rts([])) - .mockResolvedValueOnce( - rts([channel('C9', 'launch-team'), channel('C8', 'launch-team-ops')]) - ) + .mockResolvedValueOnce(rts([channel('C9', 'launch-team'), channel('C8', 'launch-team-ops')])) const result = await searchSlack(api, { ...input, query: '', @@ -451,7 +449,9 @@ describe('Slack channels', () => { 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>).') + expect(result.message).toContain( + 'Channels with similar names: #company-announcements (in:<#C7>).' + ) }) it('keeps the empty search when the channel lookup fails', async () => { From f22e0ad5fa274ecd5340fa6bfc9bfabee13ba3f4 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 13 Nov 2026 13:08:43 -0800 Subject: [PATCH 3/3] fix(search): say a filtered Slack channel is missing only after the whole lookup, and only when no result page remains --- .../sim/lib/sim-search/live/providers.test.ts | 31 +++++++++++++++++++ apps/sim/lib/sim-search/live/slack.ts | 16 +++++++--- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/apps/sim/lib/sim-search/live/providers.test.ts b/apps/sim/lib/sim-search/live/providers.test.ts index 162edb1b4cc..065636be387 100644 --- a/apps/sim/lib/sim-search/live/providers.test.ts +++ b/apps/sim/lib/sim-search/live/providers.test.ts @@ -454,6 +454,37 @@ describe('Slack channels', () => { ) }) + 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 diff --git a/apps/sim/lib/sim-search/live/slack.ts b/apps/sim/lib/sim-search/live/slack.ts index 0300d3674e0..abb77bc2e42 100644 --- a/apps/sim/lib/sim-search/live/slack.ts +++ b/apps/sim/lib/sim-search/live/slack.ts @@ -91,7 +91,7 @@ async function channelFilterNote( channel_types: channelTypes, include_archived_channels: input.policy?.includeArchived ?? true, content_types: ['channels'], - limit: MAX_LISTED_CHANNELS, + limit: SLACK_PAGE_SIZE, }, }) .then(slackResult) @@ -104,9 +104,12 @@ async function channelFilterNote( 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([ - `No channel named #${name} is visible to this account.`, - channels.length ? `Channels with similar names: ${channels.map(channelLabel).join(', ')}.` : '', + complete ? `No channel named #${name} is visible to this account.` : '', + similar ? `Channels with similar names: ${similar}.` : '', ]) } @@ -295,10 +298,13 @@ async function searchSlackPage( }) } const listed = listChannels ? searchableChannels(data, input).slice(0, MAX_LISTED_CHANNELS) : [] - const filtered = !documents.length && !cursor ? filteredChannelName(input) : undefined + 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, + 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(', ')}.` : '',