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
1 change: 1 addition & 0 deletions integrations/gsheets/definitions/secrets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']
2 changes: 1 addition & 1 deletion integrations/gsheets/integration.definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
9 changes: 1 addition & 8 deletions integrations/gsheets/linkTemplate.vrl
Original file line number Diff line number Diff line change
@@ -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 }}"
3 changes: 2 additions & 1 deletion integrations/gsheets/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
4 changes: 3 additions & 1 deletion integrations/gsheets/src/google-api/google-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
7 changes: 6 additions & 1 deletion integrations/gsheets/src/google-api/oauth-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
18 changes: 11 additions & 7 deletions integrations/gsheets/src/webhook-events/handler-dispatcher.ts
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
213 changes: 213 additions & 0 deletions integrations/gsheets/src/webhook-events/handlers/oauth-wizard.ts
Original file line number Diff line number Diff line change
@@ -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<sdk.Response> => {
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.

<script>
let pickerApiLoaded = false;

function onPickerApiLoad() {
pickerApiLoaded = true;
}

function createPicker() {
if (!pickerApiLoaded) {
alert('The file picker is still loading. Please try again in a moment.');
return;
}

new google.picker.PickerBuilder()
.addView(new google.picker.DocsView(google.picker.ViewId.SPREADSHEETS)
.setIncludeFolders(true)
.setSelectFolderEnabled(true)
.setMode(google.picker.DocsViewMode.LIST))
.enableFeature(google.picker.Feature.NAV_HIDDEN)
.enableFeature(google.picker.Feature.MULTISELECT_ENABLED)
.setTitle('Select the spreadsheets and folders you wish to share with Botpress')
.setOAuthToken(${JSON.stringify(accessToken)})
.setDeveloperKey(${JSON.stringify(bp.secrets.FILE_PICKER_API_KEY)})
.setCallback((data) => {
if (data[google.picker.Response.ACTION] == google.picker.Action.PICKED) {
document.location.href = ${JSON.stringify(oauthWizard.getWizardStepUrl('end', ctx).href)};
} else if (data[google.picker.Response.ACTION] == google.picker.Action.CANCEL) {
// User closed the picker without selecting files.
// They can still click "Skip file selection" to continue.
}
})
.setSize(640,790)
// App ID must be the Google Cloud project number (numeric prefix of CLIENT_ID)
// Format: <project-number>-<rest>.apps.googleusercontent.com
.setAppId(${JSON.stringify(bp.secrets.CLIENT_ID.split('-')[0])})
.build().setVisible(true);
}
</script>
<script async defer src="https://apis.google.com/js/api.js" onload="gapi.load('picker', onPickerApiLoad)"></script>
`,
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<string> => {
const oauth2Client = await getAuthenticatedOAuth2Client({ client, ctx })
const { token } = await oauth2Client.getAccessToken()

if (!token) {
throw new sdk.RuntimeError('Failed to get access token')
}

return token
}
3 changes: 3 additions & 0 deletions integrations/gsheets/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "preact",
"types": ["preact"],
"baseUrl": ".",
"outDir": "dist",
"experimentalDecorators": true,
Expand Down
2 changes: 1 addition & 1 deletion plugins/hitl/plugin.definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
4 changes: 1 addition & 3 deletions plugins/hitl/src/conv-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export class ConversationManager {
await this.respond({ type: 'text', text })
}

public async respond(messagePayload: types.MessagePayload, _tags: types.MessageTags = {}): Promise<void> {
public async respond(messagePayload: types.MessagePayload, tags: types.MessageTags = {}): Promise<void> {
// 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
Expand All @@ -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<string, string> = {} // re-enable tags, when the new hub allows updating or upgrading plugins

switch (messagePayload.type) {
case 'text':
await this._conversation.createMessage({
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading