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
31 changes: 19 additions & 12 deletions apps/docs/content/docs/integrations/slack.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -956,7 +956,7 @@ Rename the Slack agent session associated with a thread.

### Slack List Channels

List all channels in a Slack workspace. Returns public and private channels the bot has access to.
List accessible Slack conversations. Credential-group user tokens also return one-to-one and group direct messages.

#### Input

Expand All @@ -973,26 +973,33 @@ List all channels in a Slack workspace. Returns public and private channels the

| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `channels` | array | Array of channel objects from the workspace |
| ↳ `id` | string | Channel ID \(e.g., C1234567890\) |
| ↳ `name` | string | Channel name without # prefix |
| `channels` | array | Accessible public and private channels, plus direct and group DMs for credential-group user tokens |
| ↳ `id` | string | Conversation ID \(for example, C123, D123, or G123\) |
| ↳ `name` | string | Channel or group-DM name; omitted for one-to-one direct messages |
| ↳ `is_channel` | boolean | Whether this is a channel |
| ↳ `is_private` | boolean | Whether channel is private |
| ↳ `is_archived` | boolean | Whether channel is archived |
| ↳ `is_private` | boolean | Whether the conversation is private |
| ↳ `is_archived` | boolean | Whether the conversation is archived |
| ↳ `is_general` | boolean | Whether this is the general channel |
| ↳ `is_member` | boolean | Whether the bot/user is a member |
| ↳ `is_member` | boolean | Whether the credential owner is a member |
| ↳ `is_shared` | boolean | Whether channel is shared across workspaces |
| ↳ `is_ext_shared` | boolean | Whether channel is externally shared |
| ↳ `is_org_shared` | boolean | Whether channel is org-wide shared |
| ↳ `num_members` | number | Number of members in the channel |
| ↳ `topic` | string | Channel topic |
| ↳ `purpose` | string | Channel purpose/description |
| ↳ `topic` | string | Conversation topic |
| ↳ `purpose` | string | Conversation purpose |
| ↳ `created` | number | Unix timestamp when channel was created |
| ↳ `creator` | string | User ID of channel creator |
| ↳ `updated` | number | Unix timestamp of last update |
| `ids` | array | Array of channel IDs for easy access |
| `names` | array | Array of channel names for easy access |
| `count` | number | Total number of channels returned |
| ↳ `is_group` | boolean | Whether this is a legacy private channel or group direct message |
| ↳ `is_im` | boolean | Whether this is a one-to-one direct message |
| ↳ `is_mpim` | boolean | Whether this is a group direct message |
| ↳ `user` | string | Other participant user ID for a one-to-one direct message |
| ↳ `is_user_deleted` | boolean | Whether the other participant in a direct message is deactivated |
| ↳ `is_open` | boolean | Whether a direct or group-direct-message conversation is open |
| ↳ `priority` | number | Slack sidebar sort priority |
| `ids` | array | Conversation IDs for every returned channel or DM |
| `names` | array | Names of returned channels and group DMs; one-to-one DMs have no name |
| `count` | number | Total number of conversations returned |
| `nextCursor` | string | Cursor for the next page; null if no more pages |

### Slack List Channel Members
Expand Down
5 changes: 4 additions & 1 deletion apps/sim/app/api/auth/oauth/token/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -451,7 +451,10 @@ describe('OAuth Token API Routes', () => {
)

expect(response.status).toBe(200)
await expect(response.json()).resolves.toEqual({ accessToken: 'managed-access-token' })
await expect(response.json()).resolves.toEqual({
accessToken: 'managed-access-token',
credentialType: 'managed_oauth',
})
expect(mockResolveManagedOAuthCredentialToken).toHaveBeenCalledWith({
principal,
input: {
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/blocks/blocks/slack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2621,7 +2621,7 @@ Return ONLY the integer Unix timestamp - no explanations, no quotes, no extra te
channels: {
type: 'json',
description:
'Array of channel objects with properties: id, name, is_private, is_archived, is_member, num_members, topic, purpose, created, creator',
'Array of accessible conversation objects. Credential-group user tokens also include direct and group DMs, with type fields (is_channel, is_im, is_mpim) and DM participant field user.',
},
count: {
type: 'number',
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/lib/api/contracts/oauth-connections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ export const oauthTokenPostHeadersSchema = z.object({

const oauthTokenResponseSchema = z.object({
accessToken: z.string(),
credentialType: z.enum(['oauth', 'service_account']).optional(),
credentialType: z.enum(['oauth', 'managed_oauth', 'service_account']).optional(),
idToken: z.string().optional(),
instanceUrl: z.string().optional(),
/** Zoho Desk — the data-center-scoped Desk REST base for this credential. */
Expand Down
6 changes: 5 additions & 1 deletion apps/sim/lib/oauth/token-resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -541,7 +541,11 @@ describe('resolveCredentialAccessToken', () => {

expect(result).toEqual({
ok: true,
token: { accessToken: 'managed-token', idToken: 'id-1' },
token: {
accessToken: 'managed-token',
credentialType: 'managed_oauth',
idToken: 'id-1',
},
})
expect(mockExecuteManagedToken).toHaveBeenCalledWith({
principal: EXECUTOR_PRINCIPAL,
Expand Down
1 change: 1 addition & 0 deletions apps/sim/lib/oauth/token-resolution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,7 @@ export async function resolveCredentialAccessToken(
ok: true,
token: {
accessToken: result.accessToken,
credentialType: 'managed_oauth',
idToken: result.idToken,
},
}
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/tools/generated/tool-metadata.ts

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion apps/sim/tools/generated/tool-outputs.ts

Large diffs are not rendered by default.

64 changes: 64 additions & 0 deletions apps/sim/tools/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3041,6 +3041,70 @@ describe('Internal Route Trust', () => {
}
})

it('accepts credential-group provenance only from credential resolution', async () => {
const toolId = 'test_credential_type_authority'
const mockTool = {
id: toolId,
name: 'Credential Type Authority Test',
description: 'Verifies credential-derived request capabilities',
version: '1.0.0',
oauth: {
required: true,
provider: 'slack',
authoritativeParams: ['credentialType'] as const,
},
params: {
accessToken: { type: 'string', required: true, visibility: 'hidden' },
credentialType: { type: 'string', required: false, visibility: 'hidden' },
},
request: {
url: (params: Record<string, unknown>) => {
const types =
params.credentialType === 'managed_oauth' ? 'public_channel,im,mpim' : 'public_channel'
return `https://slack.com/api/conversations.list?types=${types}`
},
method: 'GET' as const,
headers: (params: Record<string, unknown>) => ({
Authorization: `Bearer ${params.accessToken}`,
}),
},
transformResponse: vi.fn().mockResolvedValue({ success: true, output: {} }),
}
;(tools as Record<string, unknown>)[toolId] = mockTool

const setTokenPayload = (payload: Record<string, unknown>) => {
mockResolveExecutorCredentialToken.mockResolvedValue(payload)
}

try {
setTokenPayload({ accessToken: 'legacy-token' })
const spoofedResult = await executeTool(toolId, {
credential: 'legacy-credential',
credentialType: 'managed_oauth',
})
expect(spoofedResult.success).toBe(true)
expect(mockSecureFetchWithPinnedIP).toHaveBeenLastCalledWith(
'https://slack.com/api/conversations.list?types=public_channel',
'93.184.216.34',
expect.anything()
)

mockSecureFetchWithPinnedIP.mockClear()
setTokenPayload({ accessToken: 'managed-token', credentialType: 'managed_oauth' })
const managedResult = await executeTool(toolId, {
credential: 'managed-credential',
})
expect(managedResult.success).toBe(true)
expect(mockSecureFetchWithPinnedIP).toHaveBeenLastCalledWith(
'https://slack.com/api/conversations.list?types=public_channel,im,mpim',
'93.184.216.34',
expect.anything()
)
} finally {
Reflect.deleteProperty(tools, toolId)
}
})

it('transports only active provenance selected for an internal model input', async () => {
const registry = new ResolvedSecretTraceRegistry([
{
Expand Down
5 changes: 4 additions & 1 deletion apps/sim/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1879,7 +1879,7 @@ async function executeToolImplementation(
const actualCredentialKind =
data.credentialType === 'service_account'
? 'service-account'
: data.credentialType === 'oauth'
: data.credentialType === 'oauth' || data.credentialType === 'managed_oauth'
? 'oauth'
: null
if (actualCredentialKind !== tool.oauth.credentialKind) {
Expand All @@ -1888,6 +1888,9 @@ async function executeToolImplementation(
}

contextParams.accessToken = data.accessToken
if (data.credentialType && tool.oauth?.authoritativeParams?.includes('credentialType')) {
contextParams.credentialType = data.credentialType
}
if (data.idToken) {
contextParams.idToken = data.idToken
}
Expand Down
152 changes: 152 additions & 0 deletions apps/sim/tools/slack/list_channels.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { slackListChannelsTool } from '@/tools/slack/list_channels'
import type { SlackListChannelsParams } from '@/tools/slack/types'

const BASE_PARAMS: SlackListChannelsParams = {
authMethod: 'oauth',
accessToken: 'xoxp-token',
botToken: '',
}

function requestUrl(params: SlackListChannelsParams): URL {
const value = slackListChannelsTool.request.url
return new URL(typeof value === 'function' ? value(params) : value)
}

describe('Slack list channels', () => {
it('includes direct and group DMs only for credential-group OAuth users', () => {
expect(requestUrl(BASE_PARAMS).searchParams.get('types')).toBe('public_channel,private_channel')
expect(requestUrl({ ...BASE_PARAMS, credentialType: 'oauth' }).searchParams.get('types')).toBe(
'public_channel,private_channel'
)
expect(
requestUrl({ ...BASE_PARAMS, credentialType: 'service_account' }).searchParams.get('types')
).toBe('public_channel,private_channel')
expect(
requestUrl({ ...BASE_PARAMS, credentialType: 'managed_oauth' }).searchParams.get('types')
).toBe('public_channel,private_channel,im,mpim')
})

it('keeps the private-channel toggle independent from managed DM access', () => {
const url = requestUrl({
...BASE_PARAMS,
credentialType: 'managed_oauth',
includePrivate: false,
})
expect(url.searchParams.get('types')).toBe('public_channel,im,mpim')
})

it('rejects invalid limits and empty cursors before the provider request', () => {
expect(() => requestUrl({ ...BASE_PARAMS, limit: 0 })).toThrow(
'Channel limit must be an integer between 1 and 200'
)
expect(() => requestUrl({ ...BASE_PARAMS, cursor: ' ' })).toThrow(
'Pagination cursor is required'
)
})

it('uses the default limit for unresolved optional workflow values', () => {
expect(requestUrl({ ...BASE_PARAMS, limit: null as never }).searchParams.get('limit')).toBe(
'100'
)
expect(requestUrl({ ...BASE_PARAMS, limit: ' ' as never }).searchParams.get('limit')).toBe(
'100'
)
})

it('preserves the documented type and participant fields for mixed conversations', async () => {
const result = await slackListChannelsTool.transformResponse!(
Response.json({
ok: true,
channels: [
{
id: 'C123',
name: 'general',
is_channel: true,
is_im: false,
is_mpim: false,
is_private: false,
is_archived: false,
is_member: true,
topic: { value: 'Company news' },
purpose: { value: 'Announcements' },
},
{
id: 'D123',
is_im: true,
is_org_shared: false,
user: 'U123',
is_user_deleted: false,
created: 1_498_500_348,
priority: 0,
},
{
id: 'G123',
name: 'mpdm-one--two-1',
is_group: true,
is_im: false,
is_mpim: true,
is_private: true,
is_open: true,
creator: 'U456',
},
],
response_metadata: { next_cursor: ' next-page ' },
})
)

expect(result.output).toEqual({
channels: [
{
id: 'C123',
name: 'general',
is_channel: true,
is_im: false,
is_mpim: false,
is_private: false,
is_archived: false,
is_member: true,
topic: 'Company news',
purpose: 'Announcements',
},
{
id: 'D123',
is_im: true,
user: 'U123',
is_user_deleted: false,
is_org_shared: false,
created: 1_498_500_348,
priority: 0,
},
{
id: 'G123',
name: 'mpdm-one--two-1',
is_group: true,
is_im: false,
is_mpim: true,
is_private: true,
is_open: true,
creator: 'U456',
},
],
ids: ['C123', 'D123', 'G123'],
names: ['general', 'mpdm-one--two-1'],
count: 3,
nextCursor: 'next-page',
})
})

it('fails fast on malformed successful responses', async () => {
await expect(
slackListChannelsTool.transformResponse!(Response.json({ ok: true }))
).rejects.toThrow('Slack returned a malformed conversations list')
await expect(
slackListChannelsTool.transformResponse!(
Response.json({ ok: true, channels: [{ id: 'D123', is_im: 'true' }] })
)
).rejects.toThrow('is_im')
})
})
Loading
Loading