import * as oauthWizard from '@botpress/common/src/oauth-wizard' import * as sdk from '@botpress/sdk' import { exchangeCodeForAccessToken } from '../auth' import { verifyOAuthCallbackHmac } from './hmac' import * as bp from '.botpress' type WizardHandler = oauthWizard.WizardStepHandler const SHOPIFY_OAUTH_SCOPES = ['read_products', 'read_orders', 'read_customers'].join(',') const SHOP_NAME_REGEX = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/i export const oauthWizardHandler = async (props: bp.HandlerProps): Promise => { const wizard = new oauthWizard.OAuthWizardBuilder(props) .addStep({ id: 'start', handler: _startHandler }) .addStep({ id: 'get-shop', handler: _getShopHandler }) .addStep({ id: 'validate-shop', handler: _validateShopHandler }) .addStep({ id: 'authorize', handler: _authorizeHandler }) .addStep({ id: 'oauth-callback', handler: _oauthCallbackHandler }) .addStep({ id: 'end', handler: _endHandler }) .build() return await wizard.handleRequest() } const _startHandler: WizardHandler = ({ responses }) => responses.displayButtons({ pageTitle: 'Connect Shopify', htmlOrMarkdownPageContents: 'This wizard will connect your Shopify store to Botpress. If the integration was previously connected, the existing connection will be reset.\n\nDo you want to continue?', buttons: [ { action: 'navigate', label: 'Yes, continue', navigateToStep: 'get-shop', buttonType: 'primary' }, { action: 'close', label: 'No, cancel', buttonType: 'secondary' }, ], }) const _getShopHandler: WizardHandler = ({ responses }) => responses.displayInput({ pageTitle: 'Enter Shopify Store', htmlOrMarkdownPageContents: 'Enter the domain of your Shopify store. It looks like `your-store.myshopify.com` — you can find it in the Shopify admin URL.', input: { label: 'e.g. your-store.myshopify.com', type: 'text' }, nextStepId: 'validate-shop', }) const _validateShopHandler: WizardHandler = async ({ client, ctx, inputValue, responses }) => { if (!inputValue) { throw new sdk.RuntimeError('Shop domain cannot be empty') } const shopDomain = normalizeShopDomain(inputValue) if (!SHOP_NAME_REGEX.test(shopDomain)) { return responses.displayButtons({ pageTitle: 'Invalid Shop Domain', htmlOrMarkdownPageContents: `"${inputValue}" doesn't look like a valid Shopify store domain. Please enter a domain like \`your-store.myshopify.com\`.`, buttons: [ { action: 'navigate', label: 'Try again', navigateToStep: 'get-shop', buttonType: 'primary' }, { action: 'close', label: 'Cancel', buttonType: 'secondary' }, ], }) } await _patchCredentialsState(client, ctx, { shopDomain, accessToken: undefined }) return responses.displayButtons({ pageTitle: 'Confirm Shopify Store', htmlOrMarkdownPageContents: `Is ${shopDomain}.myshopify.com your Shopify store?`, buttons: [ { action: 'navigate', label: 'Yes, connect', navigateToStep: 'authorize', buttonType: 'primary' }, { action: 'navigate', label: 'No, go back', navigateToStep: 'get-shop', buttonType: 'secondary' }, ], }) } const _authorizeHandler: WizardHandler = async ({ client, ctx, responses }) => { const { shopDomain } = await _getCredentialsState(client, ctx) if (!shopDomain) { throw new sdk.RuntimeError('Shop domain missing from state; please restart the wizard') } const redirectUri = oauthWizard.getWizardStepUrl('oauth-callback').toString() const authorizeUrl = `https://${shopDomain}.myshopify.com/admin/oauth/authorize` + `?client_id=${encodeURIComponent(bp.secrets.SHOPIFY_CLIENT_ID)}` + `&scope=${encodeURIComponent(SHOPIFY_OAUTH_SCOPES)}` + `&redirect_uri=${encodeURIComponent(redirectUri)}` + `&state=${encodeURIComponent(ctx.webhookId)}` return responses.redirectToExternalUrl(authorizeUrl) } const _oauthCallbackHandler: WizardHandler = async ({ query, client, ctx, logger, responses }) => { try { const state = query.get('state') if (state !== ctx.webhookId) { return responses.endWizard({ success: false, errorMessage: 'OAuth state mismatch — possible CSRF attempt. Please retry the connection.', }) } if (!verifyOAuthCallbackHmac(query, bp.secrets.SHOPIFY_CLIENT_SECRET)) { return responses.endWizard({ success: false, errorMessage: 'Shopify OAuth callback HMAC verification failed. Please retry the connection.', }) } const code = query.get('code') const shopParam = query.get('shop') if (!code || !shopParam) { return responses.endWizard({ success: false, errorMessage: 'Missing `code` or `shop` parameter on Shopify OAuth callback.', }) } const shopDomainFromCallback = shopParam.replace(/\.myshopify\.com$/i, '').toLowerCase() const stored = await _getCredentialsState(client, ctx) if (stored.shopDomain && stored.shopDomain.toLowerCase() !== shopDomainFromCallback) { return responses.endWizard({ success: false, errorMessage: `Shop mismatch: expected ${stored.shopDomain} but Shopify returned ${shopDomainFromCallback}.`, }) } const credentials = await exchangeCodeForAccessToken({ shop: shopDomainFromCallback, code }) await _patchCredentialsState(client, ctx, { shopDomain: shopDomainFromCallback, accessToken: credentials.accessToken, refreshToken: credentials.refreshToken, accessTokenExpiresAtSeconds: credentials.accessTokenExpiresAtSeconds, refreshTokenExpiresAtSeconds: credentials.refreshTokenExpiresAtSeconds, }) await client.configureIntegration({ identifier: shopDomainFromCallback }) return responses.redirectToStep('end') } catch (e) { logger.forBot().error({ err: e }, 'Shopify OAuth callback failed') return responses.endWizard({ success: false, errorMessage: e instanceof Error ? e.message : String(e), }) } } const _endHandler: WizardHandler = ({ responses }) => responses.endWizard({ success: true }) export const normalizeShopDomain = (raw: string): string => raw .trim() .toLowerCase() .replace(/^https?:\/\//, '') .replace(/\/.*$/, '') .replace(/\.myshopify\.com$/, '') type CredentialsPatch = { shopDomain?: string accessToken?: string refreshToken?: string accessTokenExpiresAtSeconds?: number refreshTokenExpiresAtSeconds?: number webhookSubscriptionIds?: string[] } // `client.patchState` has known issues — merge manually via getState/setState const _patchCredentialsState = async (client: bp.Client, ctx: bp.Context, patch: CredentialsPatch) => { const current = await _getCredentialsState(client, ctx) await client.setState({ type: 'integration', name: 'credentials', id: ctx.integrationId, payload: { ...current, ...patch }, }) } const _getCredentialsState = async (client: bp.Client, ctx: bp.Context): Promise => { try { const { state } = await client.getState({ type: 'integration', name: 'credentials', id: ctx.integrationId }) return (state?.payload as CredentialsPatch | undefined) ?? {} } catch { return {} } }