diff --git a/apps/docs/content/docs/search/slack.mdx b/apps/docs/content/docs/search/slack.mdx index be46d622a9b..4040bf7d0d9 100644 --- a/apps/docs/content/docs/search/slack.mdx +++ b/apps/docs/content/docs/search/slack.mdx @@ -115,7 +115,11 @@ To switch apps, first remove Slack connections under **Settings → Sources → ## Permissions reference -New Search member connections request these read-only **User Token Scopes**. DM scopes are requested even when DM indexing is off; the source settings determine what is indexed. Bot scopes are separate and allow Sim to receive and answer questions in Slack. +New Search member connections request these read-only **User Token Scopes**. DM scopes are requested even when DM indexing is off; the source settings determine what is indexed. Bot scopes are separate and allow Sim to receive and answer questions in Slack and list channels during source setup. + +The custom app's **Bot Token Scopes** include `channels:read` and `groups:read` for the channel picker. Private channels appear only when the bot has access. For an existing app, add these scopes under **OAuth & Permissions**, reinstall the app in Slack to approve the changes, then reconnect the bot in Sim. + +Custom and official app manifests declare the same full bot and user scope sets, including permissions reserved for additional capabilities. The table below lists the scopes requested by member indexing; declaring additional user scopes in the manifest does not automatically grant them to each member connection. | Purpose | User scopes | |---|---| @@ -146,5 +150,6 @@ See Slack's [app manifest reference](https://docs.slack.dev/reference/app-manife | Redirect mismatch | Check all three redirect URLs above against your Sim origin. | | App or workspace mismatch | Use the App ID and client credentials from the same app, and the ID of the workspace being authorized. | | Missing scopes | Compare User Token Scopes with the table above, update the Slack app, reinstall as Slack requires, and reconnect. In workspace setup, select **Search documents** in both setup screens. | +| Channel picker says Options unavailable | Check that the selected custom bot has `channels:read` and `groups:read` under Bot Token Scopes. After adding them, reinstall the app in Slack and reconnect the bot in Sim. | | Missing private-channel results | Confirm the member is in the channel and it is within the source filters. With an indexing account, confirm that account can read it too. | | Slow initial indexing | Check sync status and Slack rate limits. A large history can take multiple background runs. | diff --git a/apps/sim/lib/internal/slack/oauth.test.ts b/apps/sim/lib/internal/slack/oauth.test.ts index a8d5d6cca6f..9c518785976 100644 --- a/apps/sim/lib/internal/slack/oauth.test.ts +++ b/apps/sim/lib/internal/slack/oauth.test.ts @@ -76,6 +76,17 @@ describe('Slack bot grant policy and cleanup', () => { it('accepts the existing indexing bot scope policy', () => { expect(() => validateSlackBotAuthorization(grant)).not.toThrow() }) + it.each(['channels:read', 'groups:read'] as const)( + 'rejects a bot grant missing channel picker scope %s', + (missingScope) => { + expect(() => + validateSlackBotAuthorization({ + ...grant, + scope: SLACK_SEARCH_SCOPES.filter((scope) => scope !== missingScope).join(','), + }) + ).toThrow(`Reinstall the app with these scopes: ${missingScope}`) + } + ) it('requires the additional command scope for shared installs', () => { expect(() => validateSlackBotAuthorization(grant, [...SLACK_SEARCH_SCOPES, 'commands']) diff --git a/apps/sim/lib/internal/slack/search-client.test.ts b/apps/sim/lib/internal/slack/search-client.test.ts index 864a82df65c..ecbd3175517 100644 --- a/apps/sim/lib/internal/slack/search-client.test.ts +++ b/apps/sim/lib/internal/slack/search-client.test.ts @@ -45,6 +45,21 @@ describe('Slack Search provider verification', () => { fetchMock.mockResolvedValue(new Response(JSON.stringify(auth))) await expect(verifySlackSearchBot('token')).rejects.toThrow('Reinstall') }) + it.each(['channels:read', 'groups:read'] as const)( + 'rejects an installed bot missing channel picker scope %s', + async (missingScope) => { + fetchMock.mockResolvedValue( + reply( + auth, + SLACK_SEARCH_SCOPES.filter((scope) => scope !== missingScope) + ) + ) + await expect(verifySlackSearchBot('token')).rejects.toThrow( + `Reinstall the Slack bot with these scopes: ${missingScope}` + ) + expect(fetchMock).toHaveBeenCalledOnce() + } + ) it.each([ { deleted: true }, { is_bot: true }, diff --git a/apps/sim/lib/slack-search/constants.ts b/apps/sim/lib/slack-search/constants.ts index def905139d5..8010af77cbc 100644 --- a/apps/sim/lib/slack-search/constants.ts +++ b/apps/sim/lib/slack-search/constants.ts @@ -1,6 +1,8 @@ export const SLACK_SEARCH_SCOPES = [ 'assistant:write', 'chat:write', + 'channels:read', + 'groups:read', 'im:history', 'im:write', 'app_mentions:read', diff --git a/apps/sim/lib/slack-search/manifest.test.ts b/apps/sim/lib/slack-search/manifest.test.ts index 68bf1a3804c..733357be584 100644 --- a/apps/sim/lib/slack-search/manifest.test.ts +++ b/apps/sim/lib/slack-search/manifest.test.ts @@ -15,9 +15,10 @@ describe('Search app manifest', () => { 'app_mentions:read', 'im:write', 'im:history', + 'channels:read', + 'groups:read', ]) ) - expect(manifest.oauth_config.scopes.bot).not.toContain('groups:history') expect(manifest.oauth_config.scopes.user).toEqual( expect.arrayContaining([ 'users:read', @@ -47,26 +48,21 @@ describe('Search app manifest', () => { ).toBe(true) }) it('preserves existing member grants when updating a bot manifest', () => { - expect( - createSlackSearchManifest('Sim Search', 'Search', 'https://sim.test', ['files:read']) - .oauth_config.scopes.user - ).toContain('files:read') + const manifest = createSlackSearchManifest('Sim Search', 'Search', 'https://sim.test', [ + 'files:read', + 'files:write', + ]) + expect(manifest.oauth_config.scopes.user).toContain('files:write') + expect(manifest.oauth_config.scopes.user.filter((scope) => scope === 'files:read')).toEqual([ + 'files:read', + ]) }) - it('uses one origin for unified ingress and OAuth with only the required bot permissions', () => { + it('uses one origin for unified ingress and OAuth', () => { const manifest = createSlackSearchManifest( 'Sim Search', 'Search with sources', 'https://search-test.ngrok.app' ) - expect(manifest.oauth_config.scopes.bot).toEqual([ - 'assistant:write', - 'chat:write', - 'im:history', - 'im:write', - 'app_mentions:read', - 'users:read', - 'users:read.email', - ]) expect(manifest.settings.event_subscriptions.request_url).toBe( 'https://search-test.ngrok.app/api/webhooks/slack' ) @@ -92,23 +88,26 @@ describe('Search app manifest', () => { }) }) -it('official app declares expanded permissions without subscribing to member message events', () => { - const manifest = createSharedSlackSearchManifest('https://www.sim.ai') - expect(manifest.oauth_config.scopes.user).toEqual([ +it.each([ + { + name: 'custom', + manifest: createSlackSearchManifest('Sim Search', 'Search', 'https://sim.test'), + }, + { name: 'shared', manifest: createSharedSlackSearchManifest('https://sim.test') }, +])('$name app declares the complete bot and user scope sets without duplicates', ({ manifest }) => { + expect([...manifest.oauth_config.scopes.user].sort()).toEqual([ + 'canvases:read', + 'canvases:write', 'channels:history', 'channels:read', + 'chat:write', + 'files:read', 'groups:history', 'groups:read', 'im:history', 'im:read', 'mpim:history', 'mpim:read', - 'users:read', - 'users:read.email', - 'canvases:read', - 'canvases:write', - 'chat:write', - 'files:read', 'search:read.files', 'search:read.im', 'search:read.mpim', @@ -117,32 +116,38 @@ it('official app declares expanded permissions without subscribing to member mes 'search:read.users', 'team:read', 'usergroups:read', - ]) - expect(manifest.oauth_config.scopes.bot).toEqual([ - 'assistant:write', - 'chat:write', - 'im:history', - 'im:write', - 'app_mentions:read', 'users:read', 'users:read.email', - 'commands', + ]) + expect([...manifest.oauth_config.scopes.bot].sort()).toEqual([ + 'app_mentions:read', + 'assistant:write', 'channels:history', 'channels:manage', 'channels:read', 'channels:write.invites', + 'chat:write', 'chat:write.public', + 'commands', 'groups:history', 'groups:read', 'groups:write', 'groups:write.invites', + 'im:history', + 'im:write', 'links:read', 'links:write', 'mpim:history', 'mpim:read', 'mpim:write', 'reactions:write', + 'users:read', + 'users:read.email', ]) +}) + +it('official app declares commands and lifecycle events without member message events', () => { + const manifest = createSharedSlackSearchManifest('https://www.sim.ai') expect(manifest.features.slash_commands.map((command) => command.command)).toEqual([ '/query', '/connect', diff --git a/apps/sim/lib/slack-search/manifest.ts b/apps/sim/lib/slack-search/manifest.ts index c8ce9b5bd2e..a8d3a27f153 100644 --- a/apps/sim/lib/slack-search/manifest.ts +++ b/apps/sim/lib/slack-search/manifest.ts @@ -3,7 +3,7 @@ import { SLACK_MANAGED_USER_ENROLLMENT_CALLBACK_PATH, SLACK_SEARCH_USER_SCOPES, } from '@/lib/credential-groups/slack-managed-user-scopes' -import { SLACK_SEARCH_SCOPES, SLACK_SHARED_SEARCH_BOT_SCOPES } from '@/lib/slack-search/constants' +import { SLACK_SHARED_SEARCH_BOT_SCOPES } from '@/lib/slack-search/constants' export const SLACK_SEARCH_CALLBACK_PATH = '/api/knowledge/slack/oauth/callback' export const SLACK_SEARCH_WEBHOOK_PATH = '/api/webhooks/slack' @@ -11,7 +11,10 @@ export const SLACK_SEARCH_DEFAULT_NAME = 'Sim Search' export const SLACK_SEARCH_DEFAULT_DESCRIPTION = 'Ask questions about your organization’s knowledge and get answers with sources.' -/** Bot conversations and member indexing share one manifest and app identity. */ +/** + * Custom and shared apps declare the same permissions, including planned capabilities. + * Runtime OAuth validation requires only scopes used by implemented features. + */ export function createSlackSearchManifest( name: string, description: string, @@ -38,8 +41,40 @@ export function createSlackSearchManifest( SLACK_MANAGED_USER_ENROLLMENT_CALLBACK_PATH, ].map((path) => new URL(path, url).href), scopes: { - bot: [...SLACK_SEARCH_SCOPES], - user: [...new Set([...SLACK_SEARCH_USER_SCOPES, ...existingUserScopes])], + bot: [ + ...SLACK_SHARED_SEARCH_BOT_SCOPES, + 'channels:history', + 'channels:manage', + 'channels:write.invites', + 'chat:write.public', + 'groups:history', + 'groups:write', + 'groups:write.invites', + 'links:read', + 'links:write', + 'mpim:history', + 'mpim:read', + 'mpim:write', + 'reactions:write', + ], + user: [ + ...new Set([ + ...SLACK_SEARCH_USER_SCOPES, + 'canvases:read', + 'canvases:write', + 'chat:write', + 'files:read', + 'search:read.files', + 'search:read.im', + 'search:read.mpim', + 'search:read.private', + 'search:read.public', + 'search:read.users', + 'team:read', + 'usergroups:read', + ...existingUserScopes, + ]), + ], }, }, settings: { @@ -55,10 +90,7 @@ export function createSlackSearchManifest( } } -/** - * Declares the company app's permissions, including planned capabilities. - * Runtime OAuth validation continues to require only scopes used by implemented features. - */ +/** Adds the official app's commands and lifecycle events to the common manifest. */ export function createSharedSlackSearchManifest(origin: string) { const manifest = createSlackSearchManifest( SLACK_SEARCH_DEFAULT_NAME, @@ -87,44 +119,6 @@ export function createSharedSlackSearchManifest(origin: string) { }, ], }, - oauth_config: { - ...manifest.oauth_config, - scopes: { - bot: [ - ...SLACK_SHARED_SEARCH_BOT_SCOPES, - 'channels:history', - 'channels:manage', - 'channels:read', - 'channels:write.invites', - 'chat:write.public', - 'groups:history', - 'groups:read', - 'groups:write', - 'groups:write.invites', - 'links:read', - 'links:write', - 'mpim:history', - 'mpim:read', - 'mpim:write', - 'reactions:write', - ], - user: [ - ...SLACK_SEARCH_USER_SCOPES, - 'canvases:read', - 'canvases:write', - 'chat:write', - 'files:read', - 'search:read.files', - 'search:read.im', - 'search:read.mpim', - 'search:read.private', - 'search:read.public', - 'search:read.users', - 'team:read', - 'usergroups:read', - ], - }, - }, settings: { ...manifest.settings, event_subscriptions: {