diff --git a/integrations/gsheets/definitions/secrets.ts b/integrations/gsheets/definitions/secrets.ts index c388f8300ff..d209b0c177b 100644 --- a/integrations/gsheets/definitions/secrets.ts +++ b/integrations/gsheets/definitions/secrets.ts @@ -5,4 +5,5 @@ export const secrets = { ...posthogHelper.COMMON_SECRET_NAMES, CLIENT_ID: { description: 'Google OAuth Client ID' }, CLIENT_SECRET: { description: 'Google OAuth Client Secret' }, + FILE_PICKER_API_KEY: { description: 'The API key used to access the Google Picker API' }, } as const satisfies sdk.IntegrationDefinitionProps['secrets'] diff --git a/integrations/gsheets/integration.definition.ts b/integrations/gsheets/integration.definition.ts index 7ad3e1d761e..579fe091f3e 100644 --- a/integrations/gsheets/integration.definition.ts +++ b/integrations/gsheets/integration.definition.ts @@ -13,7 +13,7 @@ import { } from './definitions' export const INTEGRATION_NAME = 'gsheets' -export const INTEGRATION_VERSION = '2.1.5' +export const INTEGRATION_VERSION = '2.1.6' export default new sdk.IntegrationDefinition({ name: INTEGRATION_NAME, diff --git a/integrations/gsheets/linkTemplate.vrl b/integrations/gsheets/linkTemplate.vrl index 05765e9b771..23372049f7a 100644 --- a/integrations/gsheets/linkTemplate.vrl +++ b/integrations/gsheets/linkTemplate.vrl @@ -1,11 +1,4 @@ webhookId = to_string!(.webhookId) webhookUrl = to_string!(.webhookUrl) -env = to_string!(.env) -clientId = "28217397-drlib1gf7qb0l5kkmsqmbtrfmojtghp2.apps.googleusercontent.com" - -if env == "production" { - clientId = "225318133116-rm2n5rnpj4r6s6mj48dg2rl26gjss6n6.apps.googleusercontent.com" -} - -"https://accounts.google.com/o/oauth2/v2/auth?scope=https%3A//www.googleapis.com/auth/spreadsheets&access_type=offline&response_type=code&prompt=consent&state={{ webhookId }}&redirect_uri={{ webhookUrl }}/oauth&client_id={{ clientId }}" +"{{ webhookUrl }}/oauth/wizard/start?state={{ webhookId }}" diff --git a/integrations/gsheets/package.json b/integrations/gsheets/package.json index a5987b10486..c3adff6db60 100644 --- a/integrations/gsheets/package.json +++ b/integrations/gsheets/package.json @@ -18,6 +18,7 @@ "@botpress/cli": "workspace:*", "@botpress/common": "workspace:*", "@botpress/sdk": "workspace:*", - "@sentry/cli": "^2.39.1" + "@sentry/cli": "^2.39.1", + "preact": "^10.26.6" } } diff --git a/integrations/gsheets/src/google-api/google-client.ts b/integrations/gsheets/src/google-api/google-client.ts index 82d5755cf77..8d6de64514e 100644 --- a/integrations/gsheets/src/google-api/google-client.ts +++ b/integrations/gsheets/src/google-api/google-client.ts @@ -36,12 +36,14 @@ export class GoogleClient { ctx, client, authorizationCode, + redirectUri, }: { ctx: bp.Context client: bp.Client authorizationCode: string + redirectUri: string }) { - await exchangeAuthCodeAndSaveRefreshToken({ ctx, client, authorizationCode }) + await exchangeAuthCodeAndSaveRefreshToken({ ctx, client, authorizationCode, redirectUri }) } @handleErrors('Failed to get values from spreadsheet range') diff --git a/integrations/gsheets/src/google-api/oauth-client.ts b/integrations/gsheets/src/google-api/oauth-client.ts index cc928aea546..3f5918506ce 100644 --- a/integrations/gsheets/src/google-api/oauth-client.ts +++ b/integrations/gsheets/src/google-api/oauth-client.ts @@ -4,22 +4,27 @@ import * as bp from '.botpress' type GoogleOAuth2Client = InstanceType<(typeof google.auth)['OAuth2']> -const OAUTH_SCOPES = ['https://www.googleapis.com/auth/spreadsheets'] +const OAUTH_SCOPES = ['https://www.googleapis.com/auth/drive.file'] +// Note: This endpoint is used to construct the OAuth2 client but is overridden +// when exchanging tokens. The actual redirect URI is passed explicitly. const GLOBAL_OAUTH_ENDPOINT = `${process.env.BP_WEBHOOK_URL}/oauth` export const exchangeAuthCodeAndSaveRefreshToken = async ({ ctx, client, authorizationCode, + redirectUri, }: { ctx: bp.Context client: bp.Client authorizationCode: string + redirectUri: string }) => { const oauth2Client = _getPlainOAuth2Client() const { tokens } = await oauth2Client.getToken({ code: authorizationCode, + redirect_uri: redirectUri, }) if (!tokens.refresh_token) { diff --git a/integrations/gsheets/src/webhook-events/handler-dispatcher.ts b/integrations/gsheets/src/webhook-events/handler-dispatcher.ts index 1d57bffe42e..37e9c35f0e0 100644 --- a/integrations/gsheets/src/webhook-events/handler-dispatcher.ts +++ b/integrations/gsheets/src/webhook-events/handler-dispatcher.ts @@ -1,11 +1,15 @@ -import { oauthCallbackHandler } from './handlers/oauth-callback' +import * as oauthWizard from '@botpress/common/src/oauth-wizard' +import { handleOAuthWizard } from './handlers/oauth-wizard' import * as bp from '.botpress' -export const handler: bp.IntegrationProps['handler'] = async ({ client, ctx, logger, req }) => { - if (req.path === '/oauth') { - logger.forBot().info('Handling Google Sheets OAuth callback') - await oauthCallbackHandler({ client, ctx, req, logger }) - } else { - logger.forBot().debug('Received unsupported webhook event', { path: req.path, query: req.query }) +export const handler: bp.IntegrationProps['handler'] = async (props) => { + const { req, logger } = props + + if (oauthWizard.isOAuthWizardUrl(req.path)) { + logger.forBot().info('Handling Google Sheets OAuth wizard') + return await handleOAuthWizard(props) } + + logger.forBot().debug('Received unsupported webhook event', { path: req.path, query: req.query }) + return } diff --git a/integrations/gsheets/src/webhook-events/handlers/oauth-callback.ts b/integrations/gsheets/src/webhook-events/handlers/oauth-callback.ts index 0ba71f3e94a..00276e7050a 100644 --- a/integrations/gsheets/src/webhook-events/handlers/oauth-callback.ts +++ b/integrations/gsheets/src/webhook-events/handlers/oauth-callback.ts @@ -9,10 +9,15 @@ export const oauthCallbackHandler = async ({ client, ctx, req, logger }: bp.Hand return } + // Note: This handler is deprecated in favor of the OAuth wizard + // but kept for backward compatibility + const redirectUri = `${process.env.BP_WEBHOOK_URL}/oauth` + await GoogleClient.authenticateWithAuthorizationCode({ client, ctx, authorizationCode, + redirectUri, }) await client.configureIntegration({ identifier: ctx.webhookId }) diff --git a/integrations/gsheets/src/webhook-events/handlers/oauth-wizard.ts b/integrations/gsheets/src/webhook-events/handlers/oauth-wizard.ts new file mode 100644 index 00000000000..d8f7caf1b25 --- /dev/null +++ b/integrations/gsheets/src/webhook-events/handlers/oauth-wizard.ts @@ -0,0 +1,213 @@ +import * as oauthWizard from '@botpress/common/src/oauth-wizard' +import * as sdk from '@botpress/sdk' +import { GoogleClient } from 'src/google-api' +import { getAuthenticatedOAuth2Client } from 'src/google-api/oauth-client' +import * as bp from '.botpress' + +export const handleOAuthWizard = async (props: bp.HandlerProps): Promise => { + const { ctx } = props + + const wizard = new oauthWizard.OAuthWizardBuilder(props) + + .addStep({ + id: 'start', + handler({ responses }) { + return responses.displayButtons({ + pageTitle: 'Google Sheets Integration', + htmlOrMarkdownPageContents: ` + This wizard will set up your Google Sheets integration. This will allow + the integration to access your Google Sheets files. + + Do you wish to continue? + `, + buttons: [ + { + action: 'external', + label: 'Yes, continue', + navigateToUrl: _getOAuthAuthorizationUri(ctx), + buttonType: 'primary', + }, + { action: 'close', label: 'No, cancel', buttonType: 'secondary' }, + ], + }) + }, + }) + + .addStep({ + id: 'oauth-callback', + async handler({ query, responses, client, ctx, logger }) { + const authorizationCode = query.get('code') + const error = query.get('error') + const state = query.get('state') + + if (error) { + logger.forBot().error('OAuth error:', error) + return responses.endWizard({ + success: false, + errorMessage: `OAuth error: ${error}`, + }) + } + + // Validate state parameter to prevent CSRF attacks + if (state !== ctx.webhookId) { + logger + .forBot() + .error('OAuth state mismatch — possible CSRF attack', { expected: ctx.webhookId, received: state }) + return responses.endWizard({ + success: false, + errorMessage: 'Invalid OAuth state parameter.', + }) + } + + if (!authorizationCode) { + logger.forBot().error('Error extracting code from url in OAuth handler') + return responses.endWizard({ + success: false, + errorMessage: 'Error extracting code from url in OAuth handler', + }) + } + + try { + await GoogleClient.authenticateWithAuthorizationCode({ + client, + ctx, + authorizationCode, + redirectUri: _getOAuthRedirectUri().href, + }) + + // Done in order to correctly display the authorization status in the UI + await client.configureIntegration({ + identifier: ctx.webhookId, + }) + + return responses.redirectToStep('file-picker') + } catch (err) { + logger.forBot().error('Failed to authenticate with Google:', err) + return responses.endWizard({ + success: false, + errorMessage: `Authentication failed: ${err instanceof Error ? err.message : 'Unknown error'}`, + }) + } + }, + }) + + .addStep({ + id: 'file-picker', + async handler({ responses, client, ctx, logger }) { + let accessToken: string + try { + accessToken = await _getAccessToken({ client, ctx }) + } catch (err) { + logger.forBot().error('Failed to get access token for file picker:', err) + return responses.endWizard({ + success: false, + errorMessage: `Failed to get access token: ${err instanceof Error ? err.message : 'Unknown error'}`, + }) + } + + return responses.displayButtons({ + pageTitle: 'Google Sheets Integration', + htmlOrMarkdownPageContents: ` + You will now be asked to select the files and folders you wish to + grant access to. This is necessary for the integration to work + properly. + + If you do not give access to any files or folders, the integration + will only be able to access files that are created through the + integration. + + + + `, + buttons: [ + { + action: 'javascript', + label: 'Select files', + callFunction: 'createPicker', + buttonType: 'primary', + }, + { + action: 'navigate', + label: 'Skip file selection', + navigateToStep: 'end', + buttonType: 'warning', + }, + ], + }) + }, + }) + + .addStep({ + id: 'end', + handler({ responses }) { + return responses.endWizard({ + success: true, + }) + }, + }) + + .build() + + return await wizard.handleRequest() +} + +const _getOAuthAuthorizationUri = (ctx: { webhookId: string }) => + 'https://accounts.google.com/o/oauth2/v2/auth?' + + `scope=${encodeURIComponent('https://www.googleapis.com/auth/drive.file')}` + + '&access_type=offline' + + '&include_granted_scopes=true' + + '&response_type=code' + + '&prompt=consent' + + `&state=${encodeURIComponent(ctx.webhookId)}` + + `&redirect_uri=${encodeURIComponent(_getOAuthRedirectUri().href)}` + + `&client_id=${encodeURIComponent(bp.secrets.CLIENT_ID)}` + +const _getOAuthRedirectUri = () => oauthWizard.getWizardStepUrl('oauth-callback') + +const _getAccessToken = async ({ client, ctx }: { client: bp.Client; ctx: bp.Context }): Promise => { + const oauth2Client = await getAuthenticatedOAuth2Client({ client, ctx }) + const { token } = await oauth2Client.getAccessToken() + + if (!token) { + throw new sdk.RuntimeError('Failed to get access token') + } + + return token +} diff --git a/integrations/gsheets/tsconfig.json b/integrations/gsheets/tsconfig.json index 758f7d7ec50..1a09ba3c73f 100644 --- a/integrations/gsheets/tsconfig.json +++ b/integrations/gsheets/tsconfig.json @@ -1,6 +1,9 @@ { "extends": "../../tsconfig.json", "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "preact", + "types": ["preact"], "baseUrl": ".", "outDir": "dist", "experimentalDecorators": true, diff --git a/plugins/hitl/plugin.definition.ts b/plugins/hitl/plugin.definition.ts index d810721e971..1030ed93d16 100644 --- a/plugins/hitl/plugin.definition.ts +++ b/plugins/hitl/plugin.definition.ts @@ -102,7 +102,7 @@ const PLUGIN_CONFIG_SCHEMA = sdk.z.object({ export default new sdk.PluginDefinition({ name: 'hitl', - version: '1.3.0', + version: '1.4.0', title: 'Human In The Loop', description: 'Seamlessly transfer conversations to human agents', icon: 'icon.svg', diff --git a/plugins/hitl/src/conv-manager.ts b/plugins/hitl/src/conv-manager.ts index 780028de352..37145d9e7d9 100644 --- a/plugins/hitl/src/conv-manager.ts +++ b/plugins/hitl/src/conv-manager.ts @@ -85,7 +85,7 @@ export class ConversationManager { await this.respond({ type: 'text', text }) } - public async respond(messagePayload: types.MessagePayload, _tags: types.MessageTags = {}): Promise { + public async respond(messagePayload: types.MessagePayload, tags: types.MessageTags = {}): Promise { // FIXME: in the future, we should use the provided UserId so that messages // on Botpress appear to come from the agent/user instead of the // bot user. For now, this is not possible because of checks in the @@ -94,8 +94,6 @@ export class ConversationManager { // FIXME: typescript has trouble narrowing the type here, so we use a switch // statement as a workaround. - const tags: Record = {} // re-enable tags, when the new hub allows updating or upgrading plugins - switch (messagePayload.type) { case 'text': await this._conversation.createMessage({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3bb08c2db3e..1394e443421 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1212,6 +1212,9 @@ importers: '@sentry/cli': specifier: ^2.39.1 version: 2.39.1 + preact: + specifier: ^10.26.6 + version: 10.26.6 integrations/hubspot: dependencies: