diff --git a/.github/workflows/deploy-integrations-production.yml b/.github/workflows/deploy-integrations-production.yml index 7b5b2c3b829..76055886900 100644 --- a/.github/workflows/deploy-integrations-production.yml +++ b/.github/workflows/deploy-integrations-production.yml @@ -31,7 +31,7 @@ jobs: uses: ./.github/actions/deploy-integrations with: environment: 'production' - extra_filter: "-F '!docusign'" + extra_filter: "-F '!docusign' -F '!whatsapp'" force: ${{ github.event.inputs.force == 'true' }} sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }} token_cloud_ops_account: ${{ secrets.PRODUCTION_TOKEN_CLOUD_OPS_ACCOUNT }} diff --git a/integrations/whatsapp/integration.definition.ts b/integrations/whatsapp/integration.definition.ts index 38e4d0f7b01..3df20620a44 100644 --- a/integrations/whatsapp/integration.definition.ts +++ b/integrations/whatsapp/integration.definition.ts @@ -94,7 +94,7 @@ const defaultBotPhoneNumberId = { export default new IntegrationDefinition({ name: INTEGRATION_NAME, - version: '4.5.3', + version: '4.5.4', title: 'WhatsApp', description: 'Send and receive messages through WhatsApp.', icon: 'icon.svg', diff --git a/integrations/whatsapp/linkTemplate.vrl b/integrations/whatsapp/linkTemplate.vrl index cb6c0f29756..cbebd3ea224 100644 --- a/integrations/whatsapp/linkTemplate.vrl +++ b/integrations/whatsapp/linkTemplate.vrl @@ -1,4 +1,4 @@ webhookId = to_string!(.webhookId) webhookUrl = to_string!(.webhookUrl) -"{{ webhookUrl }}/oauth/wizard?state={{ webhookId }}" +"{{ webhookUrl }}/oauth/wizard/start-confirm?state={{ webhookId }}" diff --git a/integrations/whatsapp/src/webhook/handlers/oauth/index.ts b/integrations/whatsapp/src/webhook/handlers/oauth/index.ts index 20efd3d1950..58909ec171e 100644 --- a/integrations/whatsapp/src/webhook/handlers/oauth/index.ts +++ b/integrations/whatsapp/src/webhook/handlers/oauth/index.ts @@ -1,26 +1,23 @@ -import { Response } from '@botpress/sdk' -import { getSubpath } from 'src/misc/util' -import { getInterstitialUrl, redirectTo } from 'src/webhook/handlers/oauth/html-utils' -import { handleWizard } from './wizard' +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 oauthCallbackHandler = async (props: bp.HandlerProps): Promise => { +export const oauthCallbackHandler: bp.IntegrationProps['handler'] = async (props) => { const { req, logger } = props - let response: Response - const oauthSubpath = getSubpath(req.path) - try { - if (oauthSubpath?.startsWith('/wizard')) { - response = await handleWizard({ ...props, wizardPath: oauthSubpath }) - } else { - response = { - status: 404, - body: 'Invalid OAuth endpoint', - } + if (!isOAuthWizardUrl(req.path)) { + return { + status: 404, + body: 'Invalid OAuth endpoint', } - } catch (err: any) { - const errorMessage = '(OAuth registration) Error: ' + err.message + } + + try { + return await wizard.handler(props) + } catch (thrown: unknown) { + const error = thrown instanceof Error ? thrown : Error(String(thrown)) + const errorMessage = 'OAuth registration Error: ' + error.message logger.forBot().error(errorMessage) - response = redirectTo(getInterstitialUrl(false, errorMessage)) + return generateRedirection(getInterstitialUrl(false, errorMessage)) } - return response } diff --git a/integrations/whatsapp/src/webhook/handlers/oauth/wizard.ts b/integrations/whatsapp/src/webhook/handlers/oauth/wizard.ts index 6912b76b7f5..b64e1bde126 100644 --- a/integrations/whatsapp/src/webhook/handlers/oauth/wizard.ts +++ b/integrations/whatsapp/src/webhook/handlers/oauth/wizard.ts @@ -1,13 +1,13 @@ +import * as oauthWizard from '@botpress/common/src/oauth-wizard' import { Response, z } from '@botpress/sdk' import { trackIntegrationEvent } from 'src/misc/tracking' -import { getSubpath } from 'src/misc/util' import * as bp from '../../../../.botpress' import { MetaOauthClient } from '../../../auth' -import { generateButtonDialog, generateSelectDialog, getInterstitialUrl, redirectTo } from './html-utils' +import { getInterstitialUrl } from './html-utils' export type WizardHandlerProps = bp.HandlerProps & { wizardPath: string } +type WizardHandler = oauthWizard.WizardStepHandler type Credentials = Awaited> -type WizardStepHandlerProps = WizardHandlerProps & { credentials: Credentials } type WizardStep = | 'start-confirm' | 'setup' @@ -20,60 +20,64 @@ type WizardStep = const ACCESS_TOKEN_UNAVAILABLE_ERROR = 'Access token unavailable, please try again.' const WABA_ID_UNAVAILABLE_ERROR = 'WhatsApp Business Account ID unavailable, please try again.' const PHONE_NUMBER_ID_UNAVAILABLE_ERROR = 'Phone number ID unavailable, please try again.' -const INVALID_WIZARD_STEP_ERROR = 'Invalid wizard step' -export const handleWizard = async (props: WizardHandlerProps): Promise => { - const { wizardPath, client, ctx } = props - const credentials = await _getCredentialsState(client, ctx) - const stepHandlerProps = { ...props, credentials } - - const wizardSubpath = getSubpath(wizardPath) - let handlerResponse: Response | undefined = undefined - if (!wizardSubpath || wizardSubpath.startsWith('/start-confirm')) { - handlerResponse = await _handleWizardStartConfirm(stepHandlerProps) - } else if (wizardSubpath.startsWith('/setup')) { - handlerResponse = await _handleWizardSetup(stepHandlerProps) - } else if (wizardSubpath.startsWith('/get-access-token')) { - handlerResponse = await _handleWizardGetAccessToken(stepHandlerProps) - } else if (wizardSubpath.startsWith('/verify-waba')) { - handlerResponse = await _handleWizardVerifyWaba(stepHandlerProps) - } else if (wizardSubpath.startsWith('/verify-number')) { - handlerResponse = await _handleWizardVerifyNumber(stepHandlerProps) - } else if (wizardSubpath.startsWith('/wrap-up')) { - handlerResponse = await _doStepWrapUp(stepHandlerProps) - } else if (wizardSubpath.startsWith('/finish-wrap-up')) { - handlerResponse = await _doStepFinishWrapUp(stepHandlerProps) - } +export const handler = async (props: bp.HandlerProps): Promise => { + const wizard = new oauthWizard.OAuthWizardBuilder(props) + .addStep({ + id: 'start-confirm', + handler: _startConfirmHandler, + }) + .addStep({ + id: 'setup', + handler: _setupHandler, + }) + .addStep({ + id: 'get-access-token', + handler: _getAccessTokenHandler, + }) + .addStep({ + id: 'verify-waba', + handler: _verifyWabaHandler, + }) + .addStep({ + id: 'verify-number', + handler: _verifyNumberHandler, + }) + .addStep({ + id: 'wrap-up', + handler: _doStepWrapUp, + }) + .addStep({ + id: 'finish-wrap-up', + handler: _doStepFinishWrapUp, + }) + .build() - return ( - handlerResponse || { - status: 404, - body: INVALID_WIZARD_STEP_ERROR, - } - ) + const response = await wizard.handleRequest() + return response } -const _handleWizardStartConfirm = async (props: WizardStepHandlerProps): Promise => { - const { ctx } = props +const _startConfirmHandler: WizardHandler = async (props) => { + const { responses, ctx } = props await _trackWizardStep(ctx, 'start-confirm', 'started') - return generateButtonDialog({ - title: 'Reset Configuration', - description: + return responses.displayButtons({ + pageTitle: 'Reset Configuration', + htmlOrMarkdownPageContents: 'This wizard will reset your configuration, so the bot will stop working on WhatsApp until a new configuration is put in place, continue?', buttons: [ - { display: 'Yes', type: 'primary', action: 'NAVIGATE', payload: _getWizardStepUrl('setup', ctx) }, - { display: 'No', type: 'secondary', action: 'CLOSE_WINDOW' }, + { label: 'Yes', buttonType: 'primary', action: 'navigate', navigateToStep: 'setup' }, + { label: 'No', buttonType: 'secondary', action: 'close' }, ], }) } -const _handleWizardSetup = async (props: WizardStepHandlerProps): Promise => { - const { client, ctx } = props +const _setupHandler: WizardHandler = async (props) => { + const { responses, client, ctx } = props await _trackWizardStep(ctx, 'setup', 'setup-started') // Clean current state to start a fresh wizard const credentials: Credentials = { accessToken: undefined, wabaId: undefined, defaultBotPhoneNumberId: undefined } await _patchCredentialsState(client, ctx, credentials) - return redirectTo( + return responses.redirectToExternalUrl( 'https://www.facebook.com/v19.0/dialog/oauth?' + 'client_id=' + bp.secrets.CLIENT_ID + @@ -88,8 +92,8 @@ const _handleWizardSetup = async (props: WizardStepHandlerProps): Promise => { - const { req, client, ctx, logger, credentials } = props +const _getAccessTokenHandler: WizardHandler = async (props) => { + const { req, client, ctx, logger } = props await _trackWizardStep(ctx, 'get-access-token', 'started') const params = new URLSearchParams(req.query) const code = z.string().safeParse(params.get('code')).data @@ -102,49 +106,42 @@ const _handleWizardGetAccessToken = async (props: WizardStepHandlerProps): Promi if (!accessToken) { throw new Error(ACCESS_TOKEN_UNAVAILABLE_ERROR) } + const credentials = await _getCredentialsState(client, ctx) const newCredentials = { ...credentials, accessToken } await _patchCredentialsState(client, ctx, newCredentials) - return await _doStepVerifyWaba({ ...props, credentials: newCredentials }) + return await _doStepVerifyWaba(props, newCredentials) } -const _handleWizardVerifyWaba = async (props: WizardStepHandlerProps): Promise => { +const _verifyWabaHandler: WizardHandler = async (props) => { const { req } = props const params = new URLSearchParams(req.query) const wabaId = z.string().safeParse(params.get('wabaId')).data const force = !!params.get('force-step') - return await _doStepVerifyWaba(props, wabaId, force) + return await _doStepVerifyWaba(props, undefined, wabaId, force) } -const _handleWizardVerifyNumber = async (props: WizardStepHandlerProps): Promise => { +const _verifyNumberHandler: WizardHandler = async (props) => { const { req } = props const params = new URLSearchParams(req.query) const defaultBotPhoneNumberId = z.string().safeParse(params.get('defaultBotPhoneNumberId')).data const force = !!params.get('force-step') - return await _doStepVerifyNumber(props, defaultBotPhoneNumberId, force) -} - -const _getWizardStepUrl = (step: WizardStep, ctx?: bp.Context) => { - let url = `${process.env.BP_WEBHOOK_URL}/oauth/wizard/${step}` - if (ctx) { - url += `?state=${ctx.webhookId}` - } - return url -} - -const _getOAuthRedirectUri = () => { - // Identifier (state) specified in the OAuth request instead of URI - return _getWizardStepUrl('get-access-token', undefined) + return await _doStepVerifyNumber(props, undefined, defaultBotPhoneNumberId, force) } const _doStepVerifyWaba = async ( - props: WizardStepHandlerProps, + props: Parameters[number], + credentials?: Credentials, inWabaId?: string, force?: boolean ): Promise => { - const { client, ctx, logger, credentials } = props - let wabaId = inWabaId || credentials.wabaId + const { responses, client, ctx, logger } = props await _trackWizardStep(ctx, 'verify-waba') - const { accessToken } = credentials + let tmpCredentials = credentials + if (!tmpCredentials) { + tmpCredentials = await _getCredentialsState(client, ctx) + } + let wabaId = inWabaId || tmpCredentials.wabaId + const { accessToken } = tmpCredentials if (!accessToken) { throw new Error(ACCESS_TOKEN_UNAVAILABLE_ERROR) } @@ -154,15 +151,11 @@ const _doStepVerifyWaba = async ( if (businesses.length === 1) { wabaId = businesses[0]?.id } else { - return generateSelectDialog({ - title: 'Select Business', - description: 'Choose a WhatsApp Business Account to use in this bot:', - settings: { targetUrl: _getWizardStepUrl('verify-waba', ctx) }, - select: { - key: 'wabaId', - options: businesses.map((business) => ({ id: business.id, display: business.name })), - }, - additionalData: [{ key: 'state', value: ctx.webhookId }], + return responses.displayChoices({ + pageTitle: 'Select Business', + htmlOrMarkdownPageContents: 'Choose a WhatsApp Business Account to use in this bot:', + choices: businesses.map((business) => ({ value: business.id, label: business.name })), + nextStepId: 'verify-waba', }) } } @@ -171,20 +164,25 @@ const _doStepVerifyWaba = async ( throw new Error(WABA_ID_UNAVAILABLE_ERROR) } - const newCredentials = { ...credentials, wabaId } + const newCredentials = { ...tmpCredentials, wabaId } await _patchCredentialsState(client, ctx, newCredentials) - return await _doStepVerifyNumber({ ...props, credentials: newCredentials }) + return await _doStepVerifyNumber(props, newCredentials) } const _doStepVerifyNumber = async ( - props: WizardStepHandlerProps, + props: Parameters[number], + credentials?: Credentials, inDefaultBotPhoneNumberId?: string, force?: boolean ): Promise => { - const { client, ctx, logger, credentials } = props - let defaultBotPhoneNumberId = inDefaultBotPhoneNumberId || credentials.defaultBotPhoneNumberId + const { responses, client, ctx, logger } = props await _trackWizardStep(ctx, 'verify-number') - const { accessToken, wabaId } = credentials + let tmpCredentials = credentials + if (!tmpCredentials) { + tmpCredentials = await _getCredentialsState(client, ctx) + } + let defaultBotPhoneNumberId = inDefaultBotPhoneNumberId || tmpCredentials.defaultBotPhoneNumberId + const { accessToken, wabaId } = tmpCredentials if (!accessToken) { throw new Error(ACCESS_TOKEN_UNAVAILABLE_ERROR) } @@ -198,18 +196,15 @@ const _doStepVerifyNumber = async ( if (phoneNumbers.length === 1) { defaultBotPhoneNumberId = phoneNumbers[0]?.id } else { - return generateSelectDialog({ - title: 'Select the default number', - description: 'Choose a phone number from the current WhatsApp Business Account to use as default:', - settings: { targetUrl: _getWizardStepUrl('verify-number', ctx) }, - select: { - key: 'defaultBotPhoneNumberId', - options: phoneNumbers.map((phoneNumber) => ({ - id: phoneNumber.id, - display: `${phoneNumber.displayPhoneNumber} (${phoneNumber.verifiedName})`, - })), - }, - additionalData: [{ key: 'state', value: ctx.webhookId }], + return responses.displayChoices({ + pageTitle: 'Select the default number', + htmlOrMarkdownPageContents: + 'Choose a phone number from the current WhatsApp Business Account to use as default:', + choices: phoneNumbers.map((phoneNumber) => ({ + value: phoneNumber.id, + label: `${phoneNumber.displayPhoneNumber} (${phoneNumber.verifiedName})`, + })), + nextStepId: 'verify-number', }) } } @@ -218,20 +213,15 @@ const _doStepVerifyNumber = async ( throw new Error(PHONE_NUMBER_ID_UNAVAILABLE_ERROR) } - const newCredentials = { ...credentials, defaultBotPhoneNumberId } + const newCredentials = { ...tmpCredentials, defaultBotPhoneNumberId } await _patchCredentialsState(client, ctx, newCredentials) - return await _doStepWrapUp({ ...props, credentials: newCredentials }) -} - -const _doStepFinishWrapUp = async (props: WizardStepHandlerProps): Promise => { - const { ctx } = props - await _trackWizardStep(ctx, 'finish-wrap-up', 'completed') - return redirectTo(getInterstitialUrl(true)) + return await _doStepWrapUp(props) } -const _doStepWrapUp = async (props: WizardStepHandlerProps): Promise => { - const { client, ctx, logger, credentials } = props +const _doStepWrapUp: WizardHandler = async (props) => { + const { responses, client, ctx, logger } = props await _trackWizardStep(ctx, 'wrap-up', 'completed') + const credentials = await _getCredentialsState(client, ctx) const { accessToken, wabaId, defaultBotPhoneNumberId } = credentials if (!accessToken) { throw new Error(ACCESS_TOKEN_UNAVAILABLE_ERROR) @@ -249,9 +239,9 @@ const _doStepWrapUp = async (props: WizardStepHandlerProps): Promise = await oauthClient.registerNumber(defaultBotPhoneNumberId, accessToken) await oauthClient.subscribeToWebhooks(wabaId, accessToken) - return generateButtonDialog({ - title: 'Configuration Complete', - description: ` + return responses.displayButtons({ + pageTitle: 'Configuration Complete', + htmlOrMarkdownPageContents: ` Your configuration is now complete and the selected WhatsApp number will start answering as this bot, you can add the number to your personal contacts and test it. **Here are some things to verify if you are unable to talk with your bot on WhatsApp** @@ -259,14 +249,19 @@ Your configuration is now complete and the selected WhatsApp number will start a - Confirm if you added the correct number (With country and area code) - Double check if you published this bot - Wait a few hours (3-4) for Meta to process the Setup -- Verify if your display name was not denied by Meta (you will get an email in the Facebook accounts email address) - `, - buttons: [ - { display: 'Okay', type: 'primary', action: 'NAVIGATE', payload: _getWizardStepUrl('finish-wrap-up', ctx) }, - ], +- Verify if your display name was not denied by Meta (you will get an email in the Facebook accounts email address)`, + buttons: [{ label: 'Okay', buttonType: 'primary', action: 'navigate', navigateToStep: 'finish-wrap-up' }], }) } +const _doStepFinishWrapUp: WizardHandler = async (props) => { + const { responses, ctx } = props + await _trackWizardStep(ctx, 'finish-wrap-up', 'completed') + return responses.redirectToExternalUrl(getInterstitialUrl(true)) +} + +const _getOAuthRedirectUri = (ctx?: bp.Context) => oauthWizard.getWizardStepUrl('get-access-token', ctx).toString() + const _trackWizardStep = async (ctx: bp.Context, step: WizardStep, status?: string) => { await trackIntegrationEvent(ctx.botId, 'oauthSetupStep', { step, diff --git a/packages/common/src/oauth-wizard/index.ts b/packages/common/src/oauth-wizard/index.ts index 0f8cf031409..96435837bbe 100644 --- a/packages/common/src/oauth-wizard/index.ts +++ b/packages/common/src/oauth-wizard/index.ts @@ -1,4 +1,4 @@ export { OAuthWizardBuilder } from './wizard-builder' export { getWizardStepUrl, isOAuthWizardUrl, getInterstitialUrl } from './wizard-handler' export { CHOICE_PARAM, DISABLE_INTERSTITIAL_HEADER } from './consts' -export type { WizardStep, WizardStepHandler } from './types' +export type { WizardStep, WizardStepHandler, WizardStepInputProps } from './types' diff --git a/packages/common/src/oauth-wizard/types.ts b/packages/common/src/oauth-wizard/types.ts index 200fdd7d8a4..6767e514cdf 100644 --- a/packages/common/src/oauth-wizard/types.ts +++ b/packages/common/src/oauth-wizard/types.ts @@ -12,33 +12,35 @@ export type WizardStep = { handler: WizardStepHandler } -export type WizardStepHandler = ( - props: THandlerProps & { - selectedChoice?: string - query: URLSearchParams - responses: { - redirectToStep: (stepId: string) => sdk.Response - redirectToExternalUrl: (url: string) => sdk.Response - displayChoices: (props: { - pageTitle: string - htmlOrMarkdownPageContents: string - choices: { label: string; value: string }[] - nextStepId: string - }) => sdk.Response - displayButtons: (props: { - pageTitle: string - htmlOrMarkdownPageContents: string - buttons: ({ - label: string - buttonType?: 'primary' | 'secondary' | 'danger' | 'success' | 'warning' | 'info' - } & ( - | { action: 'navigate'; navigateToStep: string } - | { action: 'external'; navigateToUrl: string } - | { action: 'javascript'; callFunction: string } - | { action: 'close' } - ))[] - }) => sdk.Response - endWizard: (result: { success: true } | { success: false; errorMessage: string }) => sdk.Response - } +export type WizardStepInputProps = { + selectedChoice?: string + query: URLSearchParams + responses: { + redirectToStep: (stepId: string) => sdk.Response + redirectToExternalUrl: (url: string) => sdk.Response + displayChoices: (props: { + pageTitle: string + htmlOrMarkdownPageContents: string + choices: { label: string; value: string }[] + nextStepId: string + }) => sdk.Response + displayButtons: (props: { + pageTitle: string + htmlOrMarkdownPageContents: string + buttons: ({ + label: string + buttonType?: 'primary' | 'secondary' | 'danger' | 'success' | 'warning' | 'info' + } & ( + | { action: 'navigate'; navigateToStep: string } + | { action: 'external'; navigateToUrl: string } + | { action: 'javascript'; callFunction: string } + | { action: 'close' } + ))[] + }) => sdk.Response + endWizard: (result: { success: true } | { success: false; errorMessage: string }) => sdk.Response } +} + +export type WizardStepHandler = ( + props: THandlerProps & WizardStepInputProps ) => sdk.Response | Promise