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
10 changes: 10 additions & 0 deletions integrations/slack/definitions/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,14 @@ export const configurations = {
...SHARED_CONFIGURATION,
}),
},
manifestAppCredentials: {
title: 'App Manifest (Automatic Setup)',
description: 'Register new Slack application',
identifier: {
linkTemplateScript: 'manifestHandler.vrl',
},
schema: sdk.z.object({
...SHARED_CONFIGURATION,
}),
},
} as const satisfies sdk.IntegrationDefinitionProps['configurations']
37 changes: 37 additions & 0 deletions integrations/slack/definitions/states.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,41 @@ export const states = {
.describe('The now-revoked refresh token that was used to set up the integration'),
}),
},
manifestAppCredentials: {
type: 'integration',
schema: sdk.z.object({
appName: sdk.z.string().optional().title('Slack App Name').describe('The display name of the created Slack app'),
appConfigurationToken: sdk.z
.string()
.secret()
.title('Slack Configuration Token')
.describe('The Slack app configuration token used to create the app'),
appConfigurationRefreshToken: sdk.z
.string()
.secret()
.optional()
.title('Slack App Configuration Refresh Token')
.describe('Generated from api.slack.com/apps'),
appId: sdk.z.string().optional().title('Slack App ID').describe('The ID of the created Slack app'),
clientId: sdk.z.string().optional().title('Client ID').describe('OAuth Client ID from the manifest-created app'),
clientSecret: sdk.z
.string()
.secret()
.optional()
.title('Client Secret')
.describe('OAuth Client Secret from the manifest-created app'),
signingSecret: sdk.z
.string()
.secret()
.optional()
.title('Signing Secret')
.describe('Signing secret from the manifest-created app'),
authorizeUrl: sdk.z
.string()
.url()
.optional()
.title('Slack Authorize Url')
.describe('The default Slack app authorize url'),
}),
},
} as const satisfies sdk.IntegrationDefinitionProps['states']
23 changes: 23 additions & 0 deletions integrations/slack/hub.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,29 @@ This is the simplest way to set up the integration. To set up the Slack integrat

When using this configuration mode, a Botpress-managed Slack application will be used to connect to your workspace. The application will have the necessary permissions to send and receive messages, access channels, and perform other actions on your behalf. If you require more granular control over the permissions or prefer to use your own Slack application, you can opt for the manual configuration mode instead.

### App Manifest configuration (automatic setup)

This configuration mode automatically creates a dedicated Slack app for your bot using the Slack App Manifest API. Unlike the default OAuth method which uses a shared Botpress-managed Slack app, this gives you your own Slack app with full control, without the manual setup steps of the manual configuration mode.

#### Prerequisites

- A Slack workspace where you have admin permissions.

#### Steps

1. Navigate to [api.slack.com/apps](https://api.slack.com/apps) and log in.
2. Scroll to the bottom of the page and find the **"Your App Configuration Tokens"** section.
3. Click **"Generate Token"** next to the workspace you want to install the bot in. Copy the generated token. Note that configuration tokens expire after 12 hours.
4. In Botpress, add the Slack integration to your bot.
5. Select the **"App Manifest (Automatic Setup)"** configuration mode.
6. Save the configuration. This will trigger the setup wizard.
7. In the wizard, click **Continue**, then paste your Configuration Token and submit.
8. The wizard will automatically create a Slack app with all required scopes, event subscriptions, and interactivity pre-configured.
9. You will be redirected to Slack to authorize the app. Click **"Allow"** to install it in your workspace.
10. Once complete, you will see a success page. The integration is now configured and ready to use.

The created Slack app is fully yours and visible at [api.slack.com/apps](https://api.slack.com/apps).

### Manual configuration with a bot token

If you prefer to manually configure the integration, you can provide a bot token to connect your custom Slack application to Botpress. To set up the Slack integration manually, follow these steps:
Expand Down
2 changes: 1 addition & 1 deletion integrations/slack/integration.definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export default new IntegrationDefinition({
name: 'slack',
title: 'Slack',
description: 'Automate interactions with your team.',
version: '4.0.1',
version: '4.0.2',
icon: 'icon.svg',
readme: 'hub.md',
configuration,
Expand Down
4 changes: 4 additions & 0 deletions integrations/slack/manifestHandler.vrl
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
webhookId = to_string!(.webhookId)
webhookUrl = to_string!(.webhookUrl)

"{{ webhookUrl }}/oauth/wizard/start?state={{ webhookId }}"
4 changes: 2 additions & 2 deletions integrations/slack/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@
"@botpress/sdk": "workspace:*",
"@botpress/sdk-addons": "workspace:*",
"@bpinternal/slackdown": "^0.1.0",
"@slack/types": "^2.13.1",
"@slack/web-api": "^6.8.0",
"@slack/types": "^2.20.0",
"@slack/web-api": "^6.13.0",
"axios": "^1.3.4",
"fuse.js": "^6.6.2"
},
Expand Down
24 changes: 24 additions & 0 deletions integrations/slack/src/oauth-wizard/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { generateRedirection } from '@botpress/common/src/html-dialogs'
import { isOAuthWizardUrl, getInterstitialUrl } from '@botpress/common/src/oauth-wizard'
import * as wizard from './wizard'
import * as bp from '.botpress'

export const oauthWizardHandler: bp.IntegrationProps['handler'] = async (props) => {
const { req, logger } = props

if (!isOAuthWizardUrl(req.path)) {
return {
status: 404,
body: 'Invalid OAuth wizard endpoint',
}
}

try {
return await wizard.handler(props)
} catch (thrown: unknown) {
const error = thrown instanceof Error ? thrown : Error(String(thrown))
const errorMessage = 'OAuth wizard error: ' + error.message
logger.forBot().error(errorMessage)
return generateRedirection(getInterstitialUrl(false, errorMessage))
}
}
165 changes: 165 additions & 0 deletions integrations/slack/src/oauth-wizard/wizard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import * as oauthWizard from '@botpress/common/src/oauth-wizard'
import { RuntimeError, Response } from '@botpress/sdk'
import {
SlackManifestClient,
buildSlackAppManifest,
patchAppManifestConfigurationState,
getAppManifestConfigurationState,
} from '../slack-api/slack-manifest-client'
import { handleOAuthCallback } from '../webhook-events/handlers/oauth-callback'
import * as bp from '.botpress'

type WizardHandler = oauthWizard.WizardStepHandler<bp.HandlerProps>

export const handler = async (props: bp.HandlerProps): Promise<Response> => {
const wizard = new oauthWizard.OAuthWizardBuilder(props)
.addStep({ id: 'start', handler: _startHandler })
.addStep({ id: 'get-config-token', handler: _getConfigTokenHandler })
.addStep({ id: 'save-config-token', handler: _saveConfigTokenHandler })
.addStep({ id: 'get-config-refresh-token', handler: _getConfigRefreshTokenHandler })
.addStep({ id: 'save-config-refresh-token', handler: _saveConfigRefreshTokenHandler })
.addStep({ id: 'get-app-name', handler: _getAppNameHandler })
.addStep({ id: 'save-app-name', handler: _saveAppNameHandler })
.addStep({ id: 'create-app', handler: _createAppHandler })
.addStep({ id: 'oauth-callback', handler: _oauthCallbackHandler })
.addStep({ id: 'end', handler: _endHandler })
.build()

return await wizard.handleRequest()
}

const _startHandler: WizardHandler = async ({ client, ctx, responses }) => {
const { appConfigurationToken, appConfigurationRefreshToken, appName, appId, clientId, clientSecret, authorizeUrl } =
await getAppManifestConfigurationState(client, ctx)

if (appConfigurationToken && appConfigurationRefreshToken && !appName) {
return responses.redirectToStep('get-app-name')
}

if (appId && clientId && clientSecret && authorizeUrl) {
return responses.redirectToExternalUrl(authorizeUrl)
}

return responses.displayButtons({
pageTitle: 'Slack App Setup',
htmlOrMarkdownPageContents:
'This wizard will create a dedicated Slack app for your bot using the Slack App Manifest API.<br><br>' +
'A Slack app will be automatically created and configured with all the required permissions and settings.',
buttons: [
{ action: 'navigate', label: 'Continue', navigateToStep: 'get-config-token', buttonType: 'primary' },
{ action: 'close', label: 'Cancel', buttonType: 'secondary' },
],
})
}

const _getConfigTokenHandler: WizardHandler = async ({ client, ctx, responses }) => {
const state = await getAppManifestConfigurationState(client, ctx)
if (state.appConfigurationToken && state.appConfigurationRefreshToken) {
return responses.redirectToStep('get-app-name')
}

return responses.displayInput({
pageTitle: 'Slack App Configuration Token',
htmlOrMarkdownPageContents:
'Enter your Slack App Configuration Token.<br>You can generate one at <a href="https://api.slack.com/apps" target="_blank">api.slack.com/apps</a>.',
input: { label: 'App Configuration Token', type: 'text' },
nextStepId: 'save-config-token',
})
}

const _saveConfigTokenHandler: WizardHandler = async ({ client, ctx, responses, inputValue }) => {
if (!inputValue?.trim()) {
return responses.redirectToStep('get-config-token')
}
await patchAppManifestConfigurationState(client, ctx, { appConfigurationToken: inputValue.trim() })
return responses.redirectToStep('get-config-refresh-token')
}

const _getConfigRefreshTokenHandler: WizardHandler = (props) => {
return props.responses.displayInput({
pageTitle: 'Slack App Configuration Refresh Token',
htmlOrMarkdownPageContents:
'Enter your Slack App Configuration Refresh Token.<br>You can generate one at <a href="https://api.slack.com/apps" target="_blank">api.slack.com/apps</a>.',
input: { label: 'App Configuration Refresh Token', type: 'text' },
nextStepId: 'save-config-refresh-token',
})
}

const _saveConfigRefreshTokenHandler: WizardHandler = async ({ client, ctx, responses, inputValue }) => {
if (!inputValue?.trim()) {
return responses.redirectToStep('get-config-refresh-token')
}
await patchAppManifestConfigurationState(client, ctx, { appConfigurationRefreshToken: inputValue.trim() })
return responses.redirectToStep('get-app-name')
}

const _getAppNameHandler: WizardHandler = (props) => {
return props.responses.displayInput({
pageTitle: 'Name Your Slack App',
htmlOrMarkdownPageContents: 'Choose a name for the Slack app that will be created (max 35 characters).',
input: { label: 'e.g. My Botpress Bot', type: 'text' },
nextStepId: 'save-app-name',
})
}

const _saveAppNameHandler: WizardHandler = async ({ client, ctx, responses, inputValue }) => {
if (!inputValue || inputValue?.trim().length > 35) {
return responses.redirectToStep('get-app-name')
}
await patchAppManifestConfigurationState(client, ctx, { appName: inputValue.trim() })

return responses.redirectToStep('create-app')
}

const _createAppHandler: WizardHandler = async (props) => {
const { client, ctx, responses, logger } = props

if (ctx.configurationType !== 'manifestAppCredentials') {
throw new RuntimeError('This wizard is only available for the App Manifest configuration type')
}

const manifestState = await getAppManifestConfigurationState(client, ctx)

if (!manifestState.appConfigurationToken || !manifestState.appConfigurationRefreshToken) {
throw new RuntimeError('Slack App Configuration Token and Refresh Token are required. Please restart the wizard.')
}
const appName = manifestState.appName || 'Botpress Bot'

const webhookUrl = process.env.BP_WEBHOOK_URL!
const redirectUri = `${webhookUrl}/oauth`
const manifest = buildSlackAppManifest(webhookUrl, redirectUri, appName)
const manifestClient = await SlackManifestClient.create({
client,
ctx,
logger,
})

logger.forBot().debug('Validating Slack app manifest...')
await manifestClient.validateManifest(manifest)

logger.forBot().debug('Creating Slack app from manifest...')
const { app_id, credentials, oauth_authorize_url } = await manifestClient.createApp(manifest)
const authorizeUrl = new URL(oauth_authorize_url)
const oauthCallbackUrl = oauthWizard.getWizardStepUrl('oauth-callback').toString()
authorizeUrl.searchParams.set('redirect_uri', oauthCallbackUrl)
authorizeUrl.searchParams.set('state', ctx.webhookId)

await patchAppManifestConfigurationState(client, ctx, {
appId: app_id,
clientId: credentials.client_id,
clientSecret: credentials.client_secret,
signingSecret: credentials.signing_secret,
authorizeUrl: authorizeUrl.toString(),
})
return responses.redirectToExternalUrl(authorizeUrl.toString())
}

const _oauthCallbackHandler: WizardHandler = async ({ req, client, ctx, logger, responses }) => {
await handleOAuthCallback({ req, client, ctx, logger })

return responses.redirectToStep('end')
}

const _endHandler: WizardHandler = ({ responses }) => {
return responses.endWizard({ success: true })
}
18 changes: 8 additions & 10 deletions integrations/slack/src/setup.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import * as sdk from '@botpress/client'
import { RuntimeError } from '@botpress/sdk'
import { isValidUrl } from './misc/utils'
import { SlackClient } from './slack-api'
import type * as bp from '.botpress'

const REQUIRED_SLACK_SCOPES = [
export const REQUIRED_SLACK_SCOPES = [
'channels:history',
'channels:manage',
'channels:read',
Expand Down Expand Up @@ -39,7 +39,7 @@ export const register: bp.IntegrationProps['register'] = async ({ client, ctx, l
!ctx.configuration.clientId ||
!ctx.configuration.clientSecret
) {
throw new sdk.RuntimeError(
throw new RuntimeError(
'Missing configuration: Refresh Token, Signing Secret, Client ID, and Client Secret are all required when using manual configuration'
)
}
Expand Down Expand Up @@ -82,7 +82,7 @@ export const register: bp.IntegrationProps['register'] = async ({ client, ctx, l
const grantedScopes = slackClient.getGrantedScopes()
const missingScopes = REQUIRED_SLACK_SCOPES.filter((scope) => !grantedScopes.includes(scope))

throw new sdk.RuntimeError(
throw new RuntimeError(
'The Slack access token is missing required scopes. Please re-authorize the app.\n\n' +
`Missing scopes: ${missingScopes.join(', ')}.\n` +
`Granted scopes: ${grantedScopes.join(', ')}.`
Expand Down Expand Up @@ -122,24 +122,22 @@ const _validateTokenType = (ctx: bp.Context) => {
if (ctx.configurationType !== 'refreshToken') return

if (ctx.configuration.refreshToken.startsWith('xapp-')) {
throw new sdk.RuntimeError(
throw new RuntimeError(
'App-level tokens (tokens beginning with xapp) are not supported. Please provide either a bot refresh token or a bot access token.'
)
} else if (ctx.configuration.refreshToken.startsWith('xoxp-')) {
throw new sdk.RuntimeError(
throw new RuntimeError(
'User tokens (tokens beginning with xoxp) are not supported. Please provide either a bot refresh token or a bot access token.'
)
} else if (ctx.configuration.refreshToken.startsWith('xoxe.xoxb-1-')) {
throw new sdk.RuntimeError(
throw new RuntimeError(
'Rotating bot tokens (tokens beginning with xoxe.xoxb) are not supported. Please provide either a bot refresh token or a bot access token.'
)
} else if (
!ctx.configuration.refreshToken.startsWith('xoxe-1-') &&
!ctx.configuration.refreshToken.startsWith('xoxb-')
) {
throw new sdk.RuntimeError(
'Unknown Slack token type. Please provide either a bot refresh token or a bot access token.'
)
throw new RuntimeError('Unknown Slack token type. Please provide either a bot refresh token or a bot access token.')
}
}

Expand Down
12 changes: 6 additions & 6 deletions integrations/slack/src/slack-api/card-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,29 +38,29 @@ export const renderCard = (payload: Card): ChatPostMessageArguments['blocks'] =>
]

const _renderButtonUrl = (action: CardAction) => ({
type: 'button',
type: 'button' as const,
text: {
type: 'plain_text',
type: 'plain_text' as const,
text: action.label,
},
url: action.value,
})

const _renderButtonPostback = (action: CardAction) => ({
type: 'button',
type: 'button' as const,
action_id: 'postback',
text: {
type: 'plain_text',
type: 'plain_text' as const,
text: action.label,
},
value: action.value,
})

const _renderButtonSay = (action: CardAction) => ({
type: 'button',
type: 'button' as const,
action_id: 'say',
text: {
type: 'plain_text',
type: 'plain_text' as const,
text: action.label,
},
value: action.value,
Expand Down
Loading
Loading