From 475d679ac84a13baa0ae2d655006e9cd592565c3 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Tue, 1 Sep 2026 11:45:05 -0700 Subject: [PATCH] fix(slack): paginate selector resources --- apps/sim/lib/selectors/manifest.ts | 4 + .../selectors/server/providers/slack.test.ts | 236 +++++++- .../lib/selectors/server/providers/slack.ts | 511 ++++++++++++++---- 3 files changed, 614 insertions(+), 137 deletions(-) diff --git a/apps/sim/lib/selectors/manifest.ts b/apps/sim/lib/selectors/manifest.ts index 9b5756ae1d8..b08e91c4668 100644 --- a/apps/sim/lib/selectors/manifest.ts +++ b/apps/sim/lib/selectors/manifest.ts @@ -205,6 +205,8 @@ export const selectorManifest = { 'zoom.meetings': providerSelector([], { listMode: 'paginated', detail: true }), 'slack.channels': providerSelector([], { sourceFields: { oauthCredential: ['botToken'] }, + listMode: 'paginated', + detail: true, }), 'snowflake.databases': providerSelector(['database', 'schema'], { detail: true, @@ -240,6 +242,8 @@ export const selectorManifest = { }), 'slack.users': providerSelector([], { sourceFields: { oauthCredential: ['botToken'] }, + listMode: 'paginated', + detail: true, }), 'outlook.folders': providerSelector([], { listMode: 'paginated', detail: true }), 'outlook.calendars': providerSelector([], { listMode: 'paginated', detail: true }), diff --git a/apps/sim/lib/selectors/server/providers/slack.test.ts b/apps/sim/lib/selectors/server/providers/slack.test.ts index 293af8084b9..a7b8a373f07 100644 --- a/apps/sim/lib/selectors/server/providers/slack.test.ts +++ b/apps/sim/lib/selectors/server/providers/slack.test.ts @@ -22,22 +22,75 @@ import { createSelectorProtectedValues } from '@/lib/selectors/server/protected- import { slackSelectorAttachments } from '@/lib/selectors/server/providers/slack' import type { ExecuteServerSelectorArgs } from '@/lib/selectors/server/types' -function channelArgs(signal?: AbortSignal): ExecuteServerSelectorArgs { +const SCOPED_ACCOUNT_ID = 'slack-usr_U12345678-123e4567-e89b-12d3-a456-426614174000' + +function args( + selectorKey: 'slack.channels' | 'slack.users', + request: ExecuteServerSelectorArgs['request'] = { kind: 'list' }, + authentication: 'bot' | 'oauth' = 'bot', + signal?: AbortSignal +): ExecuteServerSelectorArgs { return { - selectorKey: 'slack.channels', + selectorKey, context: { oauthCredential: 'credential-1' }, - request: { kind: 'list' }, + request, scope: { kind: 'workspace', workspaceId: 'workspace-1' }, workspaceId: 'workspace-1', principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, requesterUserId: 'user-1', - credential: { suppliedId: 'credential-1', fixedToken: 'xoxb-server-only-token' }, + credential: + authentication === 'bot' + ? { suppliedId: 'credential-1', fixedToken: 'xoxb-server-only-token' } + : { + suppliedId: 'credential-1', + access: { + ok: true, + credentialOwnerUserId: 'owner-1', + resolvedCredentialId: 'credential-1', + credentialType: 'oauth', + }, + }, references: new Map(), protectedValues: createSelectorProtectedValues(), signal, } } +function queueScopedAccount(): void { + queueTableRows(account, [{ accountId: SCOPED_ACCOUNT_ID }]) +} + +function requestedUrl(call: number): URL { + return new URL(String(mockFetchProviderJson.mock.calls[call]?.[0])) +} + +function channel(id: string, name: string, isPrivate = false, isMember?: boolean) { + return { id, name, is_private: isPrivate, ...(isMember ? { is_member: true } : {}) } +} + +function user(id: string, name: string, realName?: string) { + return { id, name, ...(realName ? { real_name: realName } : {}) } +} + +function slackPage>(body: T, nextCursor?: string) { + return { + ok: true, + ...body, + ...(nextCursor ? { response_metadata: { next_cursor: nextCursor } } : {}), + } +} + +function execute( + selectorKey: 'slack.channels' | 'slack.users', + request: ExecuteServerSelectorArgs['request'] = { kind: 'list' }, + authentication: 'bot' | 'oauth' = 'bot', + signal?: AbortSignal +) { + return slackSelectorAttachments[selectorKey].execute( + args(selectorKey, request, authentication, signal) + ) +} + describe('Slack server selector adapters', () => { beforeEach(() => { vi.clearAllMocks() @@ -45,14 +98,14 @@ describe('Slack server selector adapters', () => { mockResolveSelectorOAuthAccessToken.mockResolvedValue('xoxb-server-only-token') }) - it('uses the bounded provider reader and does not fall back after caller cancellation', async () => { + it('does not fall back after channel listing is cancelled', async () => { const controller = new AbortController() const abortError = new DOMException('The operation was aborted', 'AbortError') controller.abort(abortError) mockFetchProviderJson.mockRejectedValue(abortError) await expect( - slackSelectorAttachments['slack.channels'].execute(channelArgs(controller.signal)) + execute('slack.channels', { kind: 'list' }, 'bot', controller.signal) ).rejects.toBe(abortError) expect(mockFetchProviderJson).toHaveBeenCalledOnce() @@ -62,30 +115,165 @@ describe('Slack server selector adapters', () => { it('does not return a public-only fallback when membership lookup is cancelled', async () => { const controller = new AbortController() const abortError = new DOMException('The operation was aborted', 'AbortError') - queueTableRows(account, [ - { accountId: 'slack-usr_U12345678-123e4567-e89b-12d3-a456-426614174000' }, - ]) + queueScopedAccount() mockFetchProviderJson - .mockResolvedValueOnce({ - ok: true, - channels: [{ id: 'C123', name: 'general', is_private: false }], - }) + .mockResolvedValueOnce(slackPage({ channels: [channel('C123', 'general')] })) .mockImplementationOnce(async () => { controller.abort(abortError) throw abortError }) - const args = channelArgs(controller.signal) - args.credential = { - suppliedId: 'credential-1', - access: { - ok: true, - credentialOwnerUserId: 'owner-1', - resolvedCredentialId: 'credential-1', - credentialType: 'oauth', - }, - } - await expect(slackSelectorAttachments['slack.channels'].execute(args)).rejects.toBe(abortError) + await expect( + execute('slack.channels', { kind: 'list' }, 'oauth', controller.signal) + ).rejects.toBe(abortError) expect(mockFetchProviderJson).toHaveBeenCalledTimes(2) }) + + it('continues a short users page only when its returned cursor is requested', async () => { + mockFetchProviderJson + .mockResolvedValueOnce( + slackPage({ members: [user('U001', 'first', 'First User')] }, 'users-page-2') + ) + .mockResolvedValueOnce(slackPage({ members: [user('U002', 'second', 'Second User')] })) + + const first = await execute('slack.users') + expect(first).toMatchObject({ + kind: 'list', + items: [{ id: 'U001', label: 'First User' }], + nextCursor: expect.any(String), + }) + if (first.kind !== 'list' || !first.nextCursor) throw new Error('Expected a users cursor') + + await expect( + execute('slack.users', { kind: 'list', cursor: first.nextCursor }) + ).resolves.toEqual({ + kind: 'list', + items: [{ id: 'U002', label: 'Second User' }], + }) + expect(requestedUrl(0).searchParams.has('cursor')).toBe(false) + expect(requestedUrl(1).searchParams.get('cursor')).toBe('users-page-2') + }) + + it('continues public and installing-user private channel streams independently', async () => { + queueScopedAccount() + queueScopedAccount() + mockFetchProviderJson + .mockResolvedValueOnce( + slackPage( + { + channels: [channel('C001', 'general'), channel('G001', 'bot-only', true, true)], + }, + 'conversations-page-2' + ) + ) + .mockResolvedValueOnce(slackPage({ channels: [] }, 'memberships-page-2')) + .mockResolvedValueOnce(slackPage({ channels: [channel('C002', 'announcements')] })) + .mockResolvedValueOnce( + slackPage({ channels: [channel('G002', 'installing-user-private', true)] }) + ) + + const first = await execute('slack.channels', { kind: 'list' }, 'oauth') + expect(first).toMatchObject({ + kind: 'list', + items: [{ id: 'C001', label: '#general' }], + nextCursor: expect.any(String), + }) + if (first.kind !== 'list' || !first.nextCursor) throw new Error('Expected a channels cursor') + + await expect( + execute('slack.channels', { kind: 'list', cursor: first.nextCursor }, 'oauth') + ).resolves.toEqual({ + kind: 'list', + items: [ + { id: 'C002', label: '#announcements' }, + { id: 'G002', label: '#installing-user-private' }, + ], + }) + expect(requestedUrl(2).searchParams.get('cursor')).toBe('conversations-page-2') + expect(requestedUrl(3).searchParams.get('cursor')).toBe('memberships-page-2') + }) + + it('fails closed for private list and detail results when membership cannot be verified', async () => { + queueScopedAccount() + queueScopedAccount() + mockFetchProviderJson + .mockResolvedValueOnce( + slackPage({ + channels: [channel('C001', 'general'), channel('G001', 'private', true, true)], + }) + ) + .mockRejectedValueOnce(new Error('membership lookup failed')) + .mockResolvedValueOnce(slackPage({ channel: channel('G001', 'private', true, true) })) + .mockRejectedValueOnce(new Error('member list failed')) + + await expect(execute('slack.channels', { kind: 'list' }, 'oauth')).resolves.toEqual({ + kind: 'list', + items: [{ id: 'C001', label: '#general' }], + }) + await expect( + execute('slack.channels', { kind: 'detail', id: 'G001' }, 'oauth') + ).resolves.toEqual({ kind: 'detail', item: null }) + }) + + it('preserves bot-only fallback without allowing its cursor under scoped OAuth', async () => { + mockFetchProviderJson + .mockRejectedValueOnce(new Error('private scope unavailable')) + .mockResolvedValueOnce(slackPage({ channels: [channel('C001', 'general')] }, 'public-page-2')) + + const botResult = await execute('slack.channels') + expect(botResult).toMatchObject({ + kind: 'list', + items: [{ id: 'C001', label: '#general' }], + nextCursor: expect.any(String), + }) + expect(requestedUrl(0).searchParams.get('types')).toBe('public_channel,private_channel') + expect(requestedUrl(1).searchParams.get('types')).toBe('public_channel') + if (botResult.kind !== 'list' || !botResult.nextCursor) { + throw new Error('Expected a public-only bot cursor') + } + + queueTableRows(account, []) + mockFetchProviderJson.mockRejectedValueOnce(new Error('OAuth list failed')) + await expect(execute('slack.channels', { kind: 'list' }, 'oauth')).rejects.toMatchObject({ + name: 'SelectorOptionsUnavailableError', + }) + + queueScopedAccount() + await expect( + execute('slack.channels', { kind: 'list', cursor: botResult.nextCursor }, 'oauth') + ).rejects.toMatchObject({ name: 'SelectorContextUnavailableError' }) + expect(mockFetchProviderJson).toHaveBeenCalledTimes(3) + }) + + it('hydrates saved users and installing-user private channels directly by id', async () => { + mockFetchProviderJson.mockResolvedValueOnce( + slackPage({ user: user('U999', 'saved', 'Saved User') }) + ) + await expect(execute('slack.users', { kind: 'detail', id: 'U999' })).resolves.toEqual({ + kind: 'detail', + item: { id: 'U999', label: 'Saved User' }, + }) + + queueScopedAccount() + mockFetchProviderJson + .mockResolvedValueOnce(slackPage({ channel: channel('G999', 'saved-private', true, true) })) + .mockResolvedValueOnce(slackPage({ members: ['UOTHER'] }, 'members-page-2')) + .mockResolvedValueOnce(slackPage({ members: ['U12345678'] })) + await expect( + execute('slack.channels', { kind: 'detail', id: 'G999' }, 'oauth') + ).resolves.toEqual({ + kind: 'detail', + item: { id: 'G999', label: '#saved-private' }, + }) + + expect( + mockFetchProviderJson.mock.calls.map((_, index) => requestedUrl(index).pathname) + ).toEqual([ + '/api/users.info', + '/api/conversations.info', + '/api/conversations.members', + '/api/conversations.members', + ]) + expect(requestedUrl(3).searchParams.get('cursor')).toBe('members-page-2') + }) }) diff --git a/apps/sim/lib/selectors/server/providers/slack.ts b/apps/sim/lib/selectors/server/providers/slack.ts index daecdb4be2b..93f5400b315 100644 --- a/apps/sim/lib/selectors/server/providers/slack.ts +++ b/apps/sim/lib/selectors/server/providers/slack.ts @@ -2,32 +2,46 @@ import { db } from '@sim/db' import { account } from '@sim/db/schema' import { eq } from 'drizzle-orm' import { validateAlphanumericId } from '@/lib/core/security/input-validation' +import { MAX_SELECTOR_OPTIONS, MAX_SELECTOR_PAGES } from '@/lib/selectors/limits' import type { ServerSelectorKey } from '@/lib/selectors/manifest' import { resolveSelectorOAuthAccessToken } from '@/lib/selectors/server/credentials' import { SelectorConnectionUnavailableError, + SelectorContextUnavailableError, SelectorOptionsUnavailableError, } from '@/lib/selectors/server/errors' -import { flatSelectorResult } from '@/lib/selectors/server/providers/flat-results' import { fetchProviderJson } from '@/lib/selectors/server/providers/provider-http' -import type { - ExecuteServerSelectorArgs, - ServerSelectorAttachmentMap, +import { + detailSelectorResult, + type ExecuteServerSelectorArgs, + listSelectorResult, + requireListRequest, + type ServerSelectorAttachmentMap, } from '@/lib/selectors/server/types' type SlackSelectorKey = Extract -type SlackMethod = 'conversations.list' | 'users.conversations' | 'users.list' +type SlackMethod = + | 'conversations.info' + | 'conversations.list' + | 'conversations.members' + | 'users.conversations' + | 'users.info' + | 'users.list' +type SlackCursorMode = 'users' | 'scoped' | 'oauth' | 'bot-all' | 'bot-public' const SLACK_PAGE_LIMIT = 200 -const SLACK_MAX_PAGES = 10 +const SLACK_CURSOR_VERSION = '1' +const MAX_SLACK_SELECTOR_CURSOR_LENGTH = 16 * 1024 const SCOPED_USER_ID_PATTERN = /-usr_([UW][A-Z0-9]+)-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i interface SlackApiResponse { ok?: boolean error?: string + channel?: SlackChannel channels?: SlackChannel[] - members?: SlackUser[] + user?: SlackUser + members?: Array response_metadata?: { next_cursor?: string } } @@ -47,9 +61,20 @@ interface SlackUser { is_bot?: boolean } -interface SlackChannelsResult { +interface SlackChannelPage { channels: SlackChannel[] - truncated: boolean + nextCursor?: string +} + +type SlackCursorState = + | { mode: 'users'; cursor: string } + | { mode: 'scoped'; conversations?: string; memberships?: string } + | { mode: 'oauth' | 'bot-all' | 'bot-public'; conversations: string } + +interface SlackChannelAuthentication { + accessToken: string + isBotCredential: boolean + scopedUserId: string | null } function parseScopedSlackUserId(accountId: string): string | null { @@ -71,7 +96,8 @@ async function fetchSlackApi( args: ExecuteServerSelectorArgs, method: SlackMethod, accessToken: string, - params: Record + params: Record, + acceptedErrors: readonly string[] = [] ): Promise { const url = new URL(`https://slack.com/api/${method}`) for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value) @@ -97,45 +123,131 @@ async function fetchSlackApi( } throw new SelectorOptionsUnavailableError() } - if (!data.ok) throw new SelectorOptionsUnavailableError() + if (!data.ok && !acceptedErrors.includes(data.error ?? '')) { + throw new SelectorOptionsUnavailableError() + } return data } -async function fetchAllConversations( - args: ExecuteServerSelectorArgs, - method: 'conversations.list' | 'users.conversations', - accessToken: string, - params: Record -): Promise { - const channels: SlackChannel[] = [] - let cursor: string | undefined - let truncated = false - for (let page = 0; page < SLACK_MAX_PAGES; page++) { - const data = await fetchSlackApi(args, method, accessToken, { - ...params, - limit: String(SLACK_PAGE_LIMIT), - ...(cursor ? { cursor } : {}), - }) - if (Array.isArray(data.channels)) channels.push(...data.channels) - cursor = data.response_metadata?.next_cursor?.trim() || undefined - if (!cursor) break - if (page === SLACK_MAX_PAGES - 1) truncated = true +function readProviderCursor(data: SlackApiResponse): string | undefined { + const cursor = data.response_metadata?.next_cursor?.trim() || undefined + if (cursor && cursor.length > MAX_SLACK_SELECTOR_CURSOR_LENGTH) { + throw new SelectorOptionsUnavailableError() + } + return cursor +} + +function readCursorParam(params: URLSearchParams, key: string): string | undefined { + const values = params.getAll(key) + if (values.length === 0) return undefined + if (values.length !== 1) throw new SelectorContextUnavailableError() + const value = values[0]?.trim() + if (!value || value.length > MAX_SLACK_SELECTOR_CURSOR_LENGTH) { + throw new SelectorContextUnavailableError() + } + return value +} + +function parseSlackCursor(cursor: string | undefined): SlackCursorState | undefined { + if (!cursor) return undefined + if (cursor.length > MAX_SLACK_SELECTOR_CURSOR_LENGTH) { + throw new SelectorContextUnavailableError() + } + + const params = new URLSearchParams(cursor) + const allowedKeys = new Set(['v', 'mode', 'cursor', 'conversations', 'memberships']) + if ([...params.keys()].some((key) => !allowedKeys.has(key))) { + throw new SelectorContextUnavailableError() + } + const version = readCursorParam(params, 'v') + const mode = readCursorParam(params, 'mode') as SlackCursorMode | undefined + if (version !== SLACK_CURSOR_VERSION) throw new SelectorContextUnavailableError() + + if (mode === 'users') { + if (params.has('conversations') || params.has('memberships')) { + throw new SelectorContextUnavailableError() + } + const userCursor = readCursorParam(params, 'cursor') + if (!userCursor) throw new SelectorContextUnavailableError() + return { mode, cursor: userCursor } + } + + if (params.has('cursor')) throw new SelectorContextUnavailableError() + const conversations = readCursorParam(params, 'conversations') + const memberships = readCursorParam(params, 'memberships') + if (mode === 'scoped') { + if (!conversations && !memberships) throw new SelectorContextUnavailableError() + return { + mode, + ...(conversations ? { conversations } : {}), + ...(memberships ? { memberships } : {}), + } + } + if (mode !== 'oauth' && mode !== 'bot-all' && mode !== 'bot-public') { + throw new SelectorContextUnavailableError() + } + if (!conversations || memberships) throw new SelectorContextUnavailableError() + return { mode, conversations } +} + +function encodeSlackCursor(state: SlackCursorState): string { + const params = new URLSearchParams({ v: SLACK_CURSOR_VERSION, mode: state.mode }) + if (state.mode === 'users') { + params.set('cursor', state.cursor) + } else { + if (state.conversations) params.set('conversations', state.conversations) + if (state.mode === 'scoped' && state.memberships) { + params.set('memberships', state.memberships) + } } - return { channels, truncated } + const cursor = params.toString() + if (cursor.length > MAX_SLACK_SELECTOR_CURSOR_LENGTH) { + throw new SelectorOptionsUnavailableError() + } + return cursor +} + +function channelOption(channel: SlackChannel): { id: string; label: string } | null { + if (!channel.id || !channel.name || channel.is_archived) return null + const validation = validateAlphanumericId(channel.id, 'channelId', 50) + if (!validation.isValid || !/^[CDG][A-Z0-9]+$/i.test(channel.id)) return null + return { id: channel.id, label: `#${channel.name}` } +} + +function userOption(user: SlackUser): { id: string; label: string } | null { + if (!user.id || !user.name || user.deleted || user.is_bot) return null + const validation = validateAlphanumericId(user.id, 'userId', 50) + if (!validation.isValid || !/^[UW][A-Z0-9]+$/i.test(user.id)) return null + return { id: user.id, label: user.real_name || user.name } } -async function fetchChannels( +function uniqueOptions( + items: Array<{ id: string; label: string }> +): Array<{ id: string; label: string }> { + return [...new Map(items.map((item) => [item.id, item])).values()] +} + +async function fetchChannelPage( args: ExecuteServerSelectorArgs, + method: 'conversations.list' | 'users.conversations', accessToken: string, - includePrivate: boolean -): Promise { - return fetchAllConversations(args, 'conversations.list', accessToken, { - types: includePrivate ? 'public_channel,private_channel' : 'public_channel', - exclude_archived: 'true', + params: Record, + cursor?: string +): Promise { + const data = await fetchSlackApi(args, method, accessToken, { + ...params, + limit: String(SLACK_PAGE_LIMIT), + ...(cursor ? { cursor } : {}), }) + return { + channels: Array.isArray(data.channels) ? data.channels : [], + nextCursor: readProviderCursor(data), + } } -async function listSlackChannels(args: ExecuteServerSelectorArgs) { +async function resolveChannelAuthentication( + args: ExecuteServerSelectorArgs +): Promise { if (!args.credential) throw new SelectorConnectionUnavailableError() const accessToken = await resolveSelectorOAuthAccessToken({ credential: args.credential, @@ -144,81 +256,274 @@ async function listSlackChannels(args: ExecuteServerSelectorArgs) { }) const isBotCredential = Boolean(args.credential.fixedToken) || args.credential.access?.credentialType !== 'oauth' - const scopedUserId = await readScopedSlackUserId(args) + return { + accessToken, + isBotCredential, + scopedUserId: await readScopedSlackUserId(args), + } +} - let channelResult: SlackChannelsResult - try { - channelResult = await fetchChannels(args, accessToken, true) - } catch (error) { - if (args.signal?.aborted) throw error - if (!isBotCredential) throw error - channelResult = await fetchChannels(args, accessToken, false) +function assertChannelCursorMode( + cursor: SlackCursorState | undefined, + authentication: SlackChannelAuthentication +): void { + if (!cursor) return + if (cursor.mode === 'users') throw new SelectorContextUnavailableError() + if (authentication.scopedUserId) { + if (cursor.mode !== 'scoped') throw new SelectorContextUnavailableError() + return + } + if (authentication.isBotCredential) { + if (cursor.mode !== 'bot-all' && cursor.mode !== 'bot-public') { + throw new SelectorContextUnavailableError() + } + return } + if (cursor.mode !== 'oauth') throw new SelectorContextUnavailableError() +} + +async function listScopedSlackChannels( + args: ExecuteServerSelectorArgs, + authentication: SlackChannelAuthentication, + cursor: Extract | undefined +) { + const publicPage = + !cursor || cursor.conversations + ? await fetchChannelPage( + args, + 'conversations.list', + authentication.accessToken, + { + types: 'public_channel,private_channel', + exclude_archived: 'true', + }, + cursor?.conversations + ) + : undefined - let allowedPrivateChannelIds: Set | null = null - let truncated = channelResult.truncated - if (scopedUserId) { + let privatePage: SlackChannelPage | undefined + if (!cursor || cursor.memberships) { try { - const scopedResult = await fetchAllConversations(args, 'users.conversations', accessToken, { - user: scopedUserId, - types: 'private_channel', - exclude_archived: 'true', - }) - allowedPrivateChannelIds = new Set( - scopedResult.channels.flatMap((channel) => (channel.id ? [channel.id] : [])) + privatePage = await fetchChannelPage( + args, + 'users.conversations', + authentication.accessToken, + { + user: authentication.scopedUserId!, + types: 'private_channel', + exclude_archived: 'true', + }, + cursor?.memberships ) - truncated ||= scopedResult.truncated } catch (error) { if (args.signal?.aborted) throw error - // If user membership cannot be verified, fail closed for private channels. - allowedPrivateChannelIds = new Set() + privatePage = undefined } } - return { - items: channelResult.channels.flatMap((channel) => { - if (!channel.id || !channel.name || channel.is_archived) return [] - if ( - channel.is_private && - (allowedPrivateChannelIds ? !allowedPrivateChannelIds.has(channel.id) : !channel.is_member) - ) { - return [] - } - const validation = validateAlphanumericId(channel.id, 'channelId', 50) - if (!validation.isValid || !/^[CDG][A-Z0-9]+$/i.test(channel.id)) return [] - return [{ id: channel.id, label: `#${channel.name}` }] + const items = uniqueOptions([ + ...(publicPage?.channels ?? []).flatMap((channel) => { + if (channel.is_private !== false) return [] + const option = channelOption(channel) + return option ? [option] : [] + }), + ...(privatePage?.channels ?? []).flatMap((channel) => { + const option = channelOption(channel) + return option ? [option] : [] }), - truncated, + ]) + const conversations = publicPage?.nextCursor + const memberships = privatePage?.nextCursor + return listSelectorResult( + items, + conversations || memberships + ? encodeSlackCursor({ + mode: 'scoped', + ...(conversations ? { conversations } : {}), + ...(memberships ? { memberships } : {}), + }) + : undefined + ) +} + +async function listUnscopedSlackChannels( + args: ExecuteServerSelectorArgs, + authentication: SlackChannelAuthentication, + cursor: Extract | undefined +) { + let mode: 'oauth' | 'bot-all' | 'bot-public' = authentication.isBotCredential + ? cursor?.mode === 'bot-public' + ? 'bot-public' + : 'bot-all' + : 'oauth' + let page: SlackChannelPage + try { + page = await fetchChannelPage( + args, + 'conversations.list', + authentication.accessToken, + { + types: mode === 'bot-public' ? 'public_channel' : 'public_channel,private_channel', + exclude_archived: 'true', + }, + cursor?.conversations + ) + } catch (error) { + if (args.signal?.aborted) throw error + if (!authentication.isBotCredential || mode === 'bot-public') throw error + mode = 'bot-public' + page = await fetchChannelPage(args, 'conversations.list', authentication.accessToken, { + types: 'public_channel', + exclude_archived: 'true', + }) } + + const items = page.channels.flatMap((channel) => { + if (channel.is_private && !channel.is_member) return [] + const option = channelOption(channel) + return option ? [option] : [] + }) + return listSelectorResult( + items, + page.nextCursor ? encodeSlackCursor({ mode, conversations: page.nextCursor }) : undefined + ) } -async function listSlackUsers(args: ExecuteServerSelectorArgs) { +async function installingUserIsChannelMember( + args: ExecuteServerSelectorArgs, + accessToken: string, + channelId: string, + scopedUserId: string +): Promise { + let cursor: string | undefined + let examinedMembers = 0 + const seenCursors = new Set() + for (let page = 0; page < MAX_SELECTOR_PAGES; page++) { + const data = await fetchSlackApi(args, 'conversations.members', accessToken, { + channel: channelId, + limit: String(SLACK_PAGE_LIMIT), + ...(cursor ? { cursor } : {}), + }) + const remaining = MAX_SELECTOR_OPTIONS - examinedMembers + const members = Array.isArray(data.members) ? data.members.slice(0, remaining) : [] + if (members.some((member) => member === scopedUserId)) return true + examinedMembers += members.length + if (examinedMembers >= MAX_SELECTOR_OPTIONS) return false + + cursor = readProviderCursor(data) + if (!cursor || seenCursors.has(cursor)) return false + seenCursors.add(cursor) + } + return false +} + +async function hydrateSlackChannel( + args: ExecuteServerSelectorArgs, + authentication: SlackChannelAuthentication, + rawChannelId: string +) { + const channelId = rawChannelId.trim() + const validation = validateAlphanumericId(channelId, 'channelId', 50) + if (!validation.isValid || !/^[CDG][A-Z0-9]+$/i.test(channelId)) { + return detailSelectorResult(null) + } + const data = await fetchSlackApi( + args, + 'conversations.info', + authentication.accessToken, + { channel: channelId }, + ['channel_not_found'] + ) + if (!data.ok || data.channel?.id !== channelId || typeof data.channel.is_private !== 'boolean') { + return detailSelectorResult(null) + } + const option = channelOption(data.channel) + if (!option) return detailSelectorResult(null) + if (data.channel.is_private) { + if (authentication.scopedUserId) { + try { + if ( + !(await installingUserIsChannelMember( + args, + authentication.accessToken, + channelId, + authentication.scopedUserId + )) + ) { + return detailSelectorResult(null) + } + } catch (error) { + if (args.signal?.aborted) throw error + return detailSelectorResult(null) + } + } else if (!data.channel.is_member) { + return detailSelectorResult(null) + } + } + return detailSelectorResult(option) +} + +async function executeSlackChannels(args: ExecuteServerSelectorArgs) { + const cursor = args.request.kind === 'list' ? parseSlackCursor(args.request.cursor) : undefined + const authentication = await resolveChannelAuthentication(args) + if (args.request.kind === 'detail') { + return hydrateSlackChannel(args, authentication, args.request.id) + } + requireListRequest(args.selectorKey, args.request) + assertChannelCursorMode(cursor, authentication) + if (authentication.scopedUserId) { + return listScopedSlackChannels( + args, + authentication, + cursor as Extract | undefined + ) + } + return listUnscopedSlackChannels( + args, + authentication, + cursor as Extract | undefined + ) +} + +async function executeSlackUsers(args: ExecuteServerSelectorArgs) { + const request = + args.request.kind === 'list' ? requireListRequest(args.selectorKey, args.request) : null + const cursor = request ? parseSlackCursor(request.cursor) : undefined + if (cursor && cursor.mode !== 'users') throw new SelectorContextUnavailableError() if (!args.credential) throw new SelectorConnectionUnavailableError() const accessToken = await resolveSelectorOAuthAccessToken({ credential: args.credential, serviceId: 'slack', protectedValues: args.protectedValues, }) - const members: SlackUser[] = [] - let cursor: string | undefined - let truncated = false - for (let page = 0; page < SLACK_MAX_PAGES; page++) { - const data = await fetchSlackApi(args, 'users.list', accessToken, { - limit: String(SLACK_PAGE_LIMIT), - ...(cursor ? { cursor } : {}), - }) - if (Array.isArray(data.members)) members.push(...data.members) - cursor = data.response_metadata?.next_cursor?.trim() || undefined - if (!cursor) break - if (page === SLACK_MAX_PAGES - 1) truncated = true + if (args.request.kind === 'detail') { + const userId = args.request.id.trim() + const validation = validateAlphanumericId(userId, 'userId', 50) + if (!validation.isValid || !/^[UW][A-Z0-9]+$/i.test(userId)) { + return detailSelectorResult(null) + } + const data = await fetchSlackApi(args, 'users.info', accessToken, { user: userId }, [ + 'user_not_found', + ]) + if (!data.ok || data.user?.id !== userId) return detailSelectorResult(null) + return detailSelectorResult(userOption(data.user)) } - return { - items: members.flatMap((user) => { - if (!user.id || !user.name || user.deleted || user.is_bot) return [] - return [{ id: user.id, label: user.real_name || user.name }] + + const data = await fetchSlackApi(args, 'users.list', accessToken, { + limit: String(SLACK_PAGE_LIMIT), + ...(cursor?.mode === 'users' ? { cursor: cursor.cursor } : {}), + }) + const users = (data.members ?? []).filter( + (member): member is SlackUser => typeof member === 'object' && member !== null + ) + const nextCursor = readProviderCursor(data) + return listSelectorResult( + users.flatMap((user) => { + const option = userOption(user) + return option ? [option] : [] }), - truncated, - } + nextCursor ? encodeSlackCursor({ mode: 'users', cursor: nextCursor }) : undefined + ) } const credential = { @@ -232,31 +537,11 @@ export const slackSelectorAttachments = { 'slack.channels': { credential, destination: 'fixed', - execute: async (args) => { - const result = await listSlackChannels(args) - return flatSelectorResult( - args.request, - result.items, - false, - result.truncated - ? { truncated: { reason: 'provider-cap', pages: SLACK_MAX_PAGES } } - : undefined - ) - }, + execute: executeSlackChannels, }, 'slack.users': { credential, destination: 'fixed', - execute: async (args) => { - const result = await listSlackUsers(args) - return flatSelectorResult( - args.request, - result.items, - false, - result.truncated - ? { truncated: { reason: 'provider-cap', pages: SLACK_MAX_PAGES } } - : undefined - ) - }, + execute: executeSlackUsers, }, } satisfies ServerSelectorAttachmentMap