chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:34:48 +08:00
commit 77bb5bf71f
3762 changed files with 353249 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
.botpress
+49
View File
@@ -0,0 +1,49 @@
import * as sdk from '@botpress/sdk'
import { changeMessageLabels } from './actions/change-message-labels'
import { createDraft } from './actions/create-draft'
import { createLabel } from './actions/create-label'
import { deleteDraft } from './actions/delete-draft'
import { deleteLabel } from './actions/delete-label'
import { deleteMessage } from './actions/delete-message'
import { getDraft } from './actions/get-draft'
import { getLabel } from './actions/get-label'
import { getMessageAttachment } from './actions/get-message-attachment'
import { getMessageAttachmentFromMail } from './actions/get-message-attachment-from-mail'
import { getThread } from './actions/get-thread'
import { listDrafts } from './actions/list-drafts'
import { listLabels } from './actions/list-labels'
import { listThreads } from './actions/list-threads'
import { sendDraft } from './actions/send-draft'
import { trashMessage } from './actions/trash-message'
import { trashThread } from './actions/trash-thread'
import { untrashMessage } from './actions/untrash-message'
import { untrashThread } from './actions/untrash-thread'
import { updateDraft } from './actions/update-draft'
import { updateLabel } from './actions/update-label'
export const actions = {
deleteMessage,
trashMessage,
untrashMessage,
changeMessageLabels,
getMessageAttachment,
getMessageAttachmentFromMail,
listThreads,
getThread,
trashThread,
untrashThread,
listLabels,
getLabel,
createLabel,
deleteLabel,
updateLabel,
listDrafts,
getDraft,
createDraft,
deleteDraft,
updateDraft,
sendDraft,
} as const satisfies sdk.IntegrationDefinitionProps['actions']
@@ -0,0 +1,25 @@
import { z } from '@botpress/sdk'
import { ActionDef } from './types'
export const changeMessageLabels = {
title: 'Change Message Labels',
description: 'Modifies the labels on the specified message by adding or removing label IDs.',
input: {
schema: z.object({
id: z.string().title('Message ID').describe('The ID of the message to modify.'),
addLabelIds: z
.array(z.string())
.optional()
.title('Add Label IDs')
.describe('A list of IDs of labels to add to this message.'),
removeLabelIds: z
.array(z.string())
.optional()
.title('Remove Label IDs')
.describe('A list of IDs of labels to remove from this message.'),
}),
},
output: {
schema: z.object({}).title('Empty').describe('Empty output'),
},
} as const satisfies ActionDef
@@ -0,0 +1,27 @@
import { z } from '@botpress/sdk'
import { ActionDef } from './types'
export const createDraft = {
title: 'Create Draft',
description: 'Creates a new draft with the specified email content.',
input: {
schema: z.object({
to: z.string().title('To').describe('The recipient email address.'),
subject: z.string().title('Subject').describe('The subject of the email.'),
body: z.string().title('Body').describe('The body content of the email.'),
}),
},
output: {
schema: z.object({
id: z.string().optional().title('Draft ID').describe('The immutable ID of the created draft.'),
message: z
.object({
id: z.string().optional().describe('The ID of the message.'),
threadId: z.string().optional().describe('The ID of the thread.'),
})
.optional()
.title('Message')
.describe('The message content of the draft.'),
}),
},
} as const satisfies ActionDef
@@ -0,0 +1,19 @@
import { z } from '@botpress/sdk'
import { ActionDef } from './types'
export const createLabel = {
title: 'Create Label',
description: "Creates a new label in the user's mailbox.",
input: {
schema: z.object({
name: z.string().title('Name').describe('The display name of the label to create.'),
}),
},
output: {
schema: z.object({
id: z.string().optional().title('Label ID').describe('The immutable ID of the created label.'),
name: z.string().optional().title('Name').describe('The display name of the label.'),
type: z.string().optional().title('Type').describe('The owner type for the label (system or user).'),
}),
},
} as const satisfies ActionDef
@@ -0,0 +1,15 @@
import { z } from '@botpress/sdk'
import { ActionDef } from './types'
export const deleteDraft = {
title: 'Delete Draft',
description: 'Immediately and permanently deletes the specified draft. This operation cannot be undone.',
input: {
schema: z.object({
id: z.string().title('Draft ID').describe('The ID of the draft to delete.'),
}),
},
output: {
schema: z.object({}).title('Empty').describe('Empty output'),
},
} as const satisfies ActionDef
@@ -0,0 +1,16 @@
import { z } from '@botpress/sdk'
import { ActionDef } from './types'
export const deleteLabel = {
title: 'Delete Label',
description:
'Immediately and permanently deletes the specified label. Messages and threads are not deleted, they simply lose this label.',
input: {
schema: z.object({
id: z.string().title('Label ID').describe('The ID of the label to delete.'),
}),
},
output: {
schema: z.object({}).title('Empty').describe('Empty output'),
},
} as const satisfies ActionDef
@@ -0,0 +1,16 @@
import { z } from '@botpress/sdk'
import { ActionDef } from './types'
export const deleteMessage = {
title: 'Delete Message',
description:
'Immediately and permanently deletes the specified message using its ID. This operation cannot be undone. Prefer messages.trash instead',
input: {
schema: z.object({
id: z.string().title('Message ID').describe('The ID of the message to delete.'),
}),
},
output: {
schema: z.object({}).title('Empty').describe('Empty output'),
},
} as const satisfies ActionDef
@@ -0,0 +1,27 @@
import { z } from '@botpress/sdk'
import { ActionDef } from './types'
export const getDraft = {
title: 'Get Draft',
description: 'Gets the specified draft by its ID.',
input: {
schema: z.object({
id: z.string().title('Draft ID').describe('The ID of the draft to retrieve.'),
}),
},
output: {
schema: z.object({
id: z.string().optional().title('Draft ID').describe('The immutable ID of the draft.'),
message: z
.object({
id: z.string().optional().describe('The ID of the message.'),
threadId: z.string().optional().describe('The ID of the thread.'),
labelIds: z.array(z.string()).optional().describe('List of label IDs applied to the draft message.'),
snippet: z.string().optional().describe('A short part of the message text.'),
})
.optional()
.title('Message')
.describe('The message content of the draft.'),
}),
},
} as const satisfies ActionDef
@@ -0,0 +1,57 @@
import { z } from '@botpress/sdk'
import { ActionDef } from './types'
export const getLabel = {
title: 'Get Label',
description: 'Gets the specified label by its ID.',
input: {
schema: z.object({
id: z.string().title('Label ID').describe('The ID of the label to retrieve.'),
}),
},
output: {
schema: z.object({
id: z.string().optional().title('Label ID').describe('The immutable ID of the label.'),
name: z.string().optional().title('Name').describe('The display name of the label.'),
type: z.string().optional().title('Type').describe('The owner type for the label (system or user).'),
messageListVisibility: z
.string()
.optional()
.title('Message List Visibility')
.describe('The visibility of messages with this label in the message list.'),
labelListVisibility: z
.string()
.optional()
.title('Label List Visibility')
.describe('The visibility of the label in the label list.'),
messagesTotal: z
.number()
.optional()
.title('Messages Total')
.describe('The total number of messages with the label.'),
messagesUnread: z
.number()
.optional()
.title('Messages Unread')
.describe('The number of unread messages with the label.'),
threadsTotal: z
.number()
.optional()
.title('Threads Total')
.describe('The total number of threads with the label.'),
threadsUnread: z
.number()
.optional()
.title('Threads Unread')
.describe('The number of unread threads with the label.'),
color: z
.object({
backgroundColor: z.string().optional().describe('The background color.'),
textColor: z.string().optional().describe('The text color.'),
})
.optional()
.title('Color')
.describe('The color to assign to the label.'),
}),
},
} as const satisfies ActionDef
@@ -0,0 +1,18 @@
import { z } from '@botpress/sdk'
import { attachmentSchema } from './get-message-attachment'
import { ActionDef } from './types'
export const getMessageAttachmentFromMail = {
title: 'Get Message Attachment From Mail',
description: 'Gets the first attachment from a message by automatically finding the attachment ID from the message.',
input: {
schema: z.object({
messageId: z.string().title('Message ID').describe('The ID of the message containing the attachment.'),
}),
},
output: {
schema: z.object({
attachment: attachmentSchema,
}),
},
} as const satisfies ActionDef
@@ -0,0 +1,37 @@
import { z } from '@botpress/sdk'
import { ActionDef } from './types'
export const attachmentSchema = z
.object({
size: z.number().nullable().optional().title('Size').describe('The size of the attachment in bytes.'),
data: z
.string()
.nullable()
.optional()
.title('Data')
.describe('The body data of a MIME message part as a base64url encoded string.'),
attachmentId: z
.string()
.nullable()
.optional()
.title('Attachment ID')
.describe('The immutable ID of the attachment.'),
})
.title('Attachment')
.describe('The attachment retrieved from the message.')
export const getMessageAttachment = {
title: 'Get Message Attachment',
description: 'Gets the specified message attachment by its ID.',
input: {
schema: z.object({
messageId: z.string().title('Message ID').describe('The ID of the message containing the attachment.'),
attachmentId: z.string().title('Attachment ID').describe('The ID of the attachment to retrieve.'),
}),
},
output: {
schema: z.object({
attachment: attachmentSchema,
}),
},
} as const satisfies ActionDef
@@ -0,0 +1,31 @@
import { z } from '@botpress/sdk'
import { ActionDef } from './types'
export const getThread = {
title: 'Get Thread',
description: 'Gets the specified thread by its ID, including all messages in the thread.',
input: {
schema: z.object({
id: z.string().title('Thread ID').describe('The ID of the thread to retrieve.'),
}),
},
output: {
schema: z.object({
id: z.string().optional().title('Thread ID').describe('The unique ID of the thread.'),
snippet: z.string().optional().title('Snippet').describe('A short part of the message text.'),
historyId: z.string().optional().title('History ID').describe('The history ID of the thread.'),
messages: z
.array(
z.object({
id: z.string().optional().describe('The ID of the message.'),
threadId: z.string().optional().describe('The ID of the thread the message belongs to.'),
labelIds: z.array(z.string()).optional().describe('List of label IDs applied to this message.'),
snippet: z.string().optional().describe('A short part of the message text.'),
})
)
.optional()
.title('Messages')
.describe('The list of messages in the thread.'),
}),
},
} as const satisfies ActionDef
@@ -0,0 +1,35 @@
import { z } from '@botpress/sdk'
import { ActionDef } from './types'
export const listDrafts = {
title: 'List Drafts',
description: "Lists all drafts in the user's mailbox.",
input: {
schema: z.object({}),
},
output: {
schema: z.object({
drafts: z
.array(
z.object({
id: z.string().optional().describe('The immutable ID of the draft.'),
message: z
.object({
id: z.string().optional().describe('The ID of the message contained in the draft.'),
threadId: z.string().optional().describe('The ID of the thread the draft belongs to.'),
})
.optional()
.describe('The message content of the draft.'),
})
)
.optional()
.title('Drafts')
.describe('List of drafts.'),
resultSizeEstimate: z
.number()
.optional()
.title('Result Size Estimate')
.describe('Estimated total number of results.'),
}),
},
} as const satisfies ActionDef
@@ -0,0 +1,25 @@
import { z } from '@botpress/sdk'
import { ActionDef } from './types'
export const listLabels = {
title: 'List Labels',
description: "Lists all labels in the user's mailbox, including system labels and user-created labels.",
input: {
schema: z.object({}),
},
output: {
schema: z.object({
labels: z
.array(
z.object({
id: z.string().optional().describe('The immutable ID of the label.'),
name: z.string().optional().describe('The display name of the label.'),
type: z.string().optional().describe('The owner type for the label (system or user).'),
})
)
.optional()
.title('Labels')
.describe('List of labels.'),
}),
},
} as const satisfies ActionDef
@@ -0,0 +1,30 @@
import { z } from '@botpress/sdk'
import { ActionDef } from './types'
export const listThreads = {
title: 'List Threads',
description: "Lists all email threads in the user's mailbox.",
input: {
schema: z.object({}),
},
output: {
schema: z.object({
threads: z
.array(
z.object({
id: z.string().optional().describe('The unique ID of the thread.'),
snippet: z.string().optional().describe('A short part of the message text.'),
historyId: z.string().optional().describe('The history ID of the thread.'),
})
)
.optional()
.title('Threads')
.describe('List of threads in the mailbox.'),
resultSizeEstimate: z
.number()
.optional()
.title('Result Size Estimate')
.describe('Estimated total number of results.'),
}),
},
} as const satisfies ActionDef
@@ -0,0 +1,23 @@
import { z } from '@botpress/sdk'
import { ActionDef } from './types'
export const sendDraft = {
title: 'Send Draft',
description: 'Sends the specified draft. The draft will be deleted after being sent.',
input: {
schema: z.object({
id: z.string().title('Draft ID').describe('The ID of the draft to send.'),
}),
},
output: {
schema: z.object({
id: z.string().optional().title('Message ID').describe('The ID of the sent message.'),
threadId: z.string().optional().title('Thread ID').describe('The ID of the thread the message belongs to.'),
labelIds: z
.array(z.string())
.optional()
.title('Label IDs')
.describe('List of label IDs applied to the sent message.'),
}),
},
} as const satisfies ActionDef
@@ -0,0 +1,16 @@
import { z } from '@botpress/sdk'
import { ActionDef } from './types'
export const trashMessage = {
title: 'Trash Message',
description:
'Moves the specified message to the trash. The message can be restored from the trash using untrashMessage.',
input: {
schema: z.object({
id: z.string().title('Message ID').describe('The ID of the message to trash.'),
}),
},
output: {
schema: z.object({}).title('Empty').describe('Empty output'),
},
} as const satisfies ActionDef
@@ -0,0 +1,16 @@
import { z } from '@botpress/sdk'
import { ActionDef } from './types'
export const trashThread = {
title: 'Trash Thread',
description:
'Moves the specified thread to the trash. All messages in the thread will be moved to trash. The thread can be restored using untrashThread.',
input: {
schema: z.object({
id: z.string().title('Thread ID').describe('The ID of the thread to trash.'),
}),
},
output: {
schema: z.object({}).title('Empty').describe('Empty output'),
},
} as const satisfies ActionDef
@@ -0,0 +1,4 @@
import * as sdk from '@botpress/sdk'
export type ActionDefinitions = NonNullable<sdk.IntegrationDefinitionProps['actions']>
export type ActionDef = ActionDefinitions[string]
@@ -0,0 +1,15 @@
import { z } from '@botpress/sdk'
import { ActionDef } from './types'
export const untrashMessage = {
title: 'Untrash Message',
description: 'Removes the specified message from the trash and restores it to the inbox.',
input: {
schema: z.object({
id: z.string().title('Message ID').describe('The ID of the message to untrash.'),
}),
},
output: {
schema: z.object({}).title('Empty').describe('Empty output'),
},
} as const satisfies ActionDef
@@ -0,0 +1,15 @@
import { z } from '@botpress/sdk'
import { ActionDef } from './types'
export const untrashThread = {
title: 'Untrash Thread',
description: 'Removes the specified thread from the trash. All messages in the thread will be restored.',
input: {
schema: z.object({
id: z.string().title('Thread ID').describe('The ID of the thread to restore from trash.'),
}),
},
output: {
schema: z.object({}).title('Empty').describe('Empty output'),
},
} as const satisfies ActionDef
@@ -0,0 +1,28 @@
import { z } from '@botpress/sdk'
import { ActionDef } from './types'
export const updateDraft = {
title: 'Update Draft',
description: "Replaces a draft's content with the new email content provided.",
input: {
schema: z.object({
id: z.string().title('Draft ID').describe('The ID of the draft to update.'),
to: z.string().title('To').describe('The recipient email address.'),
subject: z.string().title('Subject').describe('The subject of the email.'),
body: z.string().title('Body').describe('The body content of the email.'),
}),
},
output: {
schema: z.object({
id: z.string().optional().title('Draft ID').describe('The immutable ID of the updated draft.'),
message: z
.object({
id: z.string().optional().describe('The ID of the message.'),
threadId: z.string().optional().describe('The ID of the thread.'),
})
.optional()
.title('Message')
.describe('The message content of the draft.'),
}),
},
} as const satisfies ActionDef
@@ -0,0 +1,34 @@
import { z } from '@botpress/sdk'
import { ActionDef } from './types'
export const updateLabel = {
title: 'Update Label',
description: 'Updates the specified label with new properties like name or color.',
input: {
schema: z.object({
id: z.string().title('Label ID').describe('The ID of the label to update.'),
name: z.string().optional().title('Name').describe('The new display name for the label.'),
backgroundColor: z
.string()
.optional()
.title('Background Color')
.describe('The background color as hex string (e.g., #ffffff).'),
textColor: z.string().optional().title('Text Color').describe('The text color as hex string (e.g., #000000).'),
}),
},
output: {
schema: z.object({
id: z.string().optional().title('Label ID').describe('The immutable ID of the label.'),
name: z.string().optional().title('Name').describe('The display name of the label.'),
type: z.string().optional().title('Type').describe('The owner type for the label (system or user).'),
color: z
.object({
backgroundColor: z.string().optional().describe('The background color.'),
textColor: z.string().optional().describe('The text color.'),
})
.optional()
.title('Color')
.describe('The color assigned to the label.'),
}),
},
} as const satisfies ActionDef
@@ -0,0 +1,49 @@
import * as sdk from '@botpress/sdk'
export const channels = {
channel: {
title: 'Email thread',
description: 'Messages in an email thread',
messages: {
...sdk.messages.defaults,
markdown: sdk.messages.markdown,
image: {
schema: sdk.messages.defaults.image.schema.extend({
title: sdk.z.string().optional().title('Alt text').describe('Alt text for the image'),
}),
},
audio: {
schema: sdk.messages.defaults.audio.schema.extend({
title: sdk.z.string().optional().title('Title').describe('Title for the audio file'),
}),
},
video: {
schema: sdk.messages.defaults.video.schema.extend({
title: sdk.z.string().optional().title('Title').describe('Title for the video file'),
}),
},
file: {
schema: sdk.messages.defaults.file.schema.extend({
title: sdk.z.string().optional().title('Title').describe('Title for the file'),
}),
},
bloc: {
schema: sdk.messages.markdownBloc.schema,
},
},
message: {
tags: {
id: { title: 'Gmail ID', description: 'The unique identifier of the message on Gmail' },
},
},
conversation: {
tags: {
id: { title: 'Gmail ID', description: 'The unique identifier of the email thread on Gmail' },
email: { title: 'Sender email', description: 'The email address of the sender' },
subject: { title: 'Subject', description: 'The subject of the email' },
references: { title: 'References', description: 'The contents of the References header field' },
cc: { title: 'CC', description: 'Co-recipients' },
},
},
},
} as const satisfies sdk.IntegrationDefinitionProps['channels']
@@ -0,0 +1,57 @@
import * as sdk from '@botpress/sdk'
const { z } = sdk
export const configuration = {
identifier: {
linkTemplateScript: 'linkTemplate.vrl',
},
schema: z.object({}),
} as const satisfies sdk.IntegrationDefinitionProps['configuration']
export const configurations = {
customApp: {
title: 'Manual configuration',
description: 'Configure manually with your own GCP App',
schema: z.object({
oauthClientId: z
.string()
.min(1)
.endsWith('.apps.googleusercontent.com')
.title('OAuth Client ID')
.describe('Can be found in GCC under API & Services > Credentials'),
oauthClientSecret: z
.string()
.min(1)
.title('OAuth Client Secret')
.describe('Can be found in GCC under API & Services > Credentials'),
oauthAuthorizationCode: z
.string()
.min(1)
.title('OAuth Authorization Code')
.describe('Obtained by running the OAuth flow in your browser'),
pubsubTopicName: z
.string()
.min(1)
.startsWith('projects/')
.includes('/topics/')
.title('Pub/Sub Topic Name')
.describe('Can be found in GCC under Pub/Sub > Topics'),
pubsubWebhookSharedSecret: z
.string()
.min(1)
.title('Pub/Sub Webhook Shared Secret')
.describe(
'Must be set in GCC under Pub/Sub > Subscriptions > Details > Push endpoint (in the URL is the shared secret)'
),
pubsubWebhookServiceAccount: z
.string()
.min(1)
.title('Pub/Sub Webhook Service Account')
.describe('Must be set in GCC under Pub/Sub > Subscriptions > Details > Service account'),
}),
},
} as const satisfies sdk.IntegrationDefinitionProps['configurations']
export const identifier = {
extractScript: 'extract.vrl',
} as const satisfies sdk.IntegrationDefinitionProps['identifier']
+3
View File
@@ -0,0 +1,3 @@
import * as sdk from '@botpress/sdk'
export const events = {} as const satisfies sdk.IntegrationDefinitionProps['events']
+7
View File
@@ -0,0 +1,7 @@
export * from './configuration'
export * from './channels'
export * from './user-tags'
export * from './actions'
export * from './events'
export * from './states'
export * from './secrets'
+11
View File
@@ -0,0 +1,11 @@
import { posthogHelper } from '@botpress/common'
import * as sdk from '@botpress/sdk'
export const secrets = {
...posthogHelper.COMMON_SECRET_NAMES,
CLIENT_ID: { description: 'Gmail Client ID' },
CLIENT_SECRET: { description: 'Gmail Client Secret' },
TOPIC_NAME: { description: 'Google Cloud Pub/Sub topic name for Gmail messages' },
WEBHOOK_SHARED_SECRET: { description: 'Pub/Sub webhook shared secret' },
WEBHOOK_SERVICE_ACCOUNT: { description: 'Pub/Sub JWT service account' },
} as const satisfies sdk.IntegrationDefinitionProps['secrets']
+38
View File
@@ -0,0 +1,38 @@
import * as sdk from '@botpress/sdk'
const { z } = sdk
export const states = {
thread: {
type: 'conversation',
schema: z.object({
inReplyTo: z
.string()
.title('In reply to')
.optional()
.describe('The ID of the message this message is a reply to'),
}),
},
configuration: {
type: 'integration',
schema: z.object({
refreshToken: z
.string()
.title('Refresh token')
.describe('The refresh token to use to authenticate with Gmail. It gets exchanged for a bearer token'),
lastHistoryId: z
.string()
.optional()
.title('History cursor')
.describe('The last history ID processed by the integration'),
}),
},
googlePublicCertCache: {
type: 'integration',
schema: z.object({
certificates: z
.string()
.title('Certificates JSON')
.describe('The certs used by Google for federated sign-on, stringified as JSON'),
}),
},
} as const satisfies sdk.IntegrationDefinitionProps['states']
@@ -0,0 +1,7 @@
import * as sdk from '@botpress/sdk'
export const user = {
tags: {
id: { title: 'Email address', description: 'The email address of the user' },
},
} as const satisfies sdk.IntegrationDefinitionProps['user']
+13
View File
@@ -0,0 +1,13 @@
import rootConfig from '../../eslint.config.mjs'
export default [
...rootConfig,
{
languageOptions: {
parserOptions: {
project: ['./tsconfig.json'],
tsconfigRootDir: import.meta.dirname,
},
},
},
]
+1
View File
@@ -0,0 +1 @@
parse_json!(decode_base64!(parse_json!(.body).message.data)).emailAddress
+187
View File
@@ -0,0 +1,187 @@
Simplify your email communication and supercharge your chatbot with seamless integration between Botpress and Gmail. Experience the power of combining your AI-powered chatbot with the versatility of Gmail, empowering you to streamline workflows, automate tasks, and deliver exceptional customer experiences. Unlock a world of possibilities as your chatbot seamlessly interacts with your Gmail inbox, managing emails, composing messages, and executing actions with ease. Leverage Gmail's robust features, such as advanced search, labeling, and filtering, to efficiently organize and respond to emails. Elevate your chatbot's capabilities and revolutionize your email-based interactions with the Botpress and Gmail Integration.
## Important note
Unfortunately, **automatic configuration is temporarily unavailable**.
We are currently in the process of getting our Gmail integration verified by Google. Once this verification is complete, you will be able to use the automatic configuration method to set up the Gmail integration with just a few clicks. Until then, you will need to create your own OAuth app by following the steps outlined in the `Manual configuration with OAuth` section below.
## Configuration
Due to the sensitive nature of email communication, the Gmail integration requires a secure connection between Botpress and Gmail. To establish this secure connection, you **must** configure the Gmail integration using OAuth.
### Automatic configuration with OAuth
To set up the Gmail integration using OAuth, click the authorization button and follow the on-screen instructions to connect your Botpress chatbot to Gmail.
When using this configuration mode, a Botpress-managed Gmail application will be used to connect to your Gmail account. However, actions taken by the bot will be attributed to the user who authorized the connection, rather than the application. For this reason, **we do not recommend using personal Gmail accounts** for this integration. You should set up a service account and use this account to authorize the connection.
#### Configuring the integration in Botpress
1. Authorize the Gmail integration by clicking the authorization button.
2. Follow the on-screen instructions to connect your Botpress chatbot to Gmail.
3. Once the connection is established, you can save the configuration and enable the integration.
### Manual configuration with OAuth
To set up the Gmail integration manually, you must create a Google Cloud Platform project and enable the Gmail API. You will also need to create OAuth credentials, set up a Pub/Sub topic, and configure the integration in Botpress.
#### Creating a Google Cloud Platform project
1. Go to the [Google Cloud Console](https://console.cloud.google.com/).
2. Create a new project by clicking the `Select a resource` dropdown in the top navigation bar and selecting `New Project`.
3. Follow the on-screen instructions to create the new project.
#### Enabling the Gmail API
1. In the Google Cloud Console, navigate to the `APIs & Services` section.
2. Click on `Library` in the left sidebar.
3. Search for `Gmail API` and click on the result.
4. Click the `Enable` button to enable the Gmail API for your project.
#### Configuring the OAuth consent screen
1. In the Google Cloud Console, navigate to the `APIs & Services` section.
2. Click on `OAuth consent screen` in the left sidebar.
3. Select `External` as the user type and click the `Create` button.
4. Enter a name for your application and fill in the other required fields.
5. Click the `Save and continue` button.
6. Click the `Add or remove scopes` button.
7. Under `Manually add scopes`, enter the following:
```plaintext
https://www.googleapis.com/auth/userinfo.email
https://www.googleapis.com/auth/userinfo.profile
https://www.googleapis.com/auth/gmail.readonly
https://www.googleapis.com/auth/gmail.send
```
8. Click the `Add to table` button, followed by the `Update` button.
9. Click the `Save and continue` button.
10. Under `Test users`, enter the email address of the user you would like to use with the integration.
11. Click the `Save and continue` button again.
12. Click the `Back to dashboard` button.
#### Creating OAuth credentials
1. In the Google Cloud Console, navigate to the `APIs & Services` section.
2. Click on `Credentials` in the left sidebar.
3. Click the `Create credentials` dropdown and select `OAuth client ID`.
4. Select `Web application` as the application type.
5. Enter a name for the OAuth client ID.
6. Under `Authorized redirect URIs`, enter `https://botpress.com`.
7. Click the `Create` button to create the OAuth client ID.
8. Copy the **client ID** and **client secret** for use in the next steps.
> The client ID is the string that ends with `.apps.googleusercontent.com`.
#### Creating a service account
1. In the Google Cloud Console, navigate to the `IAM & Admin` section.
2. Click on `Service accounts` in the left sidebar.
3. Click the `Create service account` button.
4. Enter a name for the service account.
5. Click the `Done` button to create the service account.
> There is no need to grant any roles to the service account, as it will only be used to sign webhook events.
6. Copy the **service account email address** for use in the next steps.
> The service account email address is the string that ends with `.gserviceaccount.com`.
#### Creating a Pub/Sub topic
1. In the Google Cloud Console, navigate to the `Pub/Sub` section.
2. Click on `Topics` in the left sidebar.
3. Click the `Create topic` button.
4. Enter a name for the topic in the `Topic ID` field.
5. Unckeck the `Add default subscription` checkbox.
6. Leave the other options unchanged.
7. Click the `Create` button to create the topic.
8. Copy the **topic name** for use in the next steps.
> The topic name is the string that starts with `projects/`.
#### Granting publish rights on the Pub/Sub topic
1. In the Google Cloud Console, navigate to the `Pub/Sub` section.
2. Click on `Topics` in the left sidebar.
3. From the topic list, find the topic you created earlier and click the triple-dot <kbd>⋮</kbd> button to the far right.
4. Select `View permissions` from the dropdown menu.
5. Click the `Add principal` button.
6. Under `Add principals`, enter `gmail-api-push@system.gserviceaccount.com`.
> This service account is managed by Google and is used to push events to Pub/Sub.
7. Under `Assign roles`, select the role `Pub/Sub Publisher`.
8. Click the `Save` button to grant publish rights to the service account.
#### Generating a shared secret
1. Generate an alphanumeric string to use as a shared secret for signing Pub/Sub push events. We recommend using a string with at least 32 characters. You can use tools like openssl or online password generators to create a secure string.
- For example, you can generate a secure string using one the following commands:
```bash
# Using openssl:
openssl rand -hex 32
# Using nodejs:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
# Using Python 3:
python3 -c "import secrets; print(secrets.token_hex(32))"
```
- You may also use an online password generator to create a secure string:
- https://bitwarden.com/password-generator/
- https://1password.com/password-generator
2. Copy the **shared secret** for use in the next steps.
#### Creating a Pub/Sub subscription
1. In the Google Cloud Console, navigate to the `Pub/Sub` section.
2. Click on `Subscriptions` in the left sidebar.
3. Click the `Create subscription` button.
4. Enter a name for the subscription in the `Subscription ID` field.
5. Select the topic you created earlier from the `Topic` dropdown.
6. Under `Delivery type`, select `Push`.
7. Enter your integration's Botpress-provided webhook URL in the `Endpoint URL` field. To this URL, append `?shared_secret=`, followed by the shared secret you generated earlier.
> For example, if your integration's webhook URL is `https://webhook.botpress.cloud/57fcfb04-51fd-4381-909a-10e6ae53d310`, the endpoint URL would be `https://webhook.botpress.cloud/57fcfb04-51fd-4381-909a-10e6ae53d310?shared_secret=your_shared_secret`.
8. Check the `Enable authentication` checkbox.
9. Enter the service account email address you created earlier in the `Service account` field.
10. Enter the shared secret you generated earlier in the `Audience` field.
11. Under `Expiration period`, select `Never expire`.
12. Under `Acknowledgement deadline`, enter `60` seconds.
13. Under `Retry policy`, select `Retry after exponential backoff delay`. Set the minimum backoff to `60` second and the maximum backoff to `600` seconds.
14. Click the `Create` button to create the subscription.
#### Authorizing the OAuth application
1. On Gmail, log in to the Google account you want to use with the Gmail integration.
2. Once logged in, go to the following URL in your browser:
```
https://accounts.google.com/o/oauth2/auth?response_type=code&scope=https://www.googleapis.com/auth/gmail.readonly%20https://www.googleapis.com/auth/gmail.send&access_type=offline&prompt=consent&redirect_uri=https://botpress.com&client_id=
```
> Make sure to add your OAuth **client ID** to the end of the URL. For example, if your OAuth client ID is `abcd`, the URL should end with `&client_id=abcd`.
3. Follow the on-screen instructions to authorize the OAuth application with your personal Gmail account.
4. You will be redirected to botpress.com. **Do not close this page**.
5. Copy the **authorization code** from the URL in your browser's address bar.
> The authorization code is the string that appears after `code=` and before `&scope=` in the URL.
> If you have difficulty finding the authorization code in the URL, you may use online tools such as https://semalt.tools/en/url-parser or https://parseurlonline.com.
6. You may now safely close this page.
#### Configuring the integration in Botpress
1. Select the `Manual` configuration mode in the Botpress integration settings.
2. Enter your OAuth **client ID** and **client secret**.
3. Enter the full **topic name**, which starts with `projects/`.
4. Enter the **shared secret** you generated earlier.
5. Enter the **service account email address** you created earlier.
6. Enter the **authorization code** you obtained earlier.
> Unfortunately, the authorization code is only valid for a short period of time. If the code has expired, you will need to repeat the steps outlined in the `Authorizing the OAuth application` section. If the authorization code is not expired, it will be exchanged for a refresh token, which will be used to authenticate the integration.
7. Save the configuration and enable the integration.
## Limitations
Botpress shall not be held responsible for any costs you may incur on the Google Cloud Platform while using the Gmail integration, should you choose to use the manual configuration mode. Ensure that you are aware of the costs associated with using the Gmail API and the Google Cloud Platform before using the manual configuration mode.
Standard Gmail API limitations apply to the Gmail integration in Botpress. These limitations include rate limits, message size restrictions, and other constraints imposed by the Gmail and Google Cloud platforms. Ensure that your chatbot adheres to these limitations to maintain optimal performance and reliability.
More details are available in the [Gmail API documentation](https://developers.google.com/gmail/api/reference/quota).
+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="7.086 -169.483 1277.149 1277.149" shape-rendering="geometricPrecision" text-rendering="geometricPrecision" image-rendering="optimizeQuality" xmlns="http://www.w3.org/2000/svg"><path fill="none" d="M1138.734 931.095h.283M1139.017 931.095h-.283"/><path d="M1179.439 7.087c57.543 0 104.627 47.083 104.627 104.626v30.331l-145.36 103.833-494.873 340.894L148.96 242.419v688.676h-37.247c-57.543 0-104.627-47.082-104.627-104.625V111.742C7.086 54.198 54.17 7.115 111.713 7.115l532.12 394.525L1179.41 7.115l.029-.028z" fill="#e75a4d"/><linearGradient id="a" gradientUnits="userSpaceOnUse" x1="1959.712" y1="737.107" x2="26066.213" y2="737.107" gradientTransform="matrix(.0283 0 0 -.0283 248.36 225.244)"><stop offset="0" stop-color="#f8f6ef"/><stop offset="1" stop-color="#e7e4d6"/></linearGradient><path fill="url(#a)" d="M111.713 7.087l532.12 394.525L1179.439 7.087z"/><path fill="#e7e4d7" d="M148.96 242.419v688.676h989.774V245.877L643.833 586.771z"/><path fill="#b8b7ae" d="M148.96 931.095l494.873-344.324-2.24-1.586L148.96 923.527z"/><path fill="#b7b6ad" d="M1138.734 245.877l.283 685.218-495.184-344.324z"/><path d="M1284.066 142.044l.17 684.51c-2.494 76.082-35.461 103.238-145.219 104.514l-.283-685.219 145.36-103.833-.028.028z" fill="#b2392f"/><linearGradient id="b" gradientUnits="userSpaceOnUse" x1="1959.712" y1="737.107" x2="26066.213" y2="737.107" gradientTransform="matrix(.0283 0 0 -.0283 248.36 225.244)"><stop offset="0" stop-color="#f8f6ef"/><stop offset="1" stop-color="#e7e4d6"/></linearGradient><path fill="url(#b)" d="M111.713 7.087l532.12 394.525L1179.439 7.087z"/><linearGradient id="c" gradientUnits="userSpaceOnUse" x1="1959.712" y1="737.107" x2="26066.213" y2="737.107" gradientTransform="matrix(.0283 0 0 -.0283 248.36 225.244)"><stop offset="0" stop-color="#f8f6ef"/><stop offset="1" stop-color="#e7e4d6"/></linearGradient><path fill="url(#c)" d="M111.713 7.087l532.12 394.525L1179.439 7.087z"/><linearGradient id="d" gradientUnits="userSpaceOnUse" x1="1959.712" y1="737.107" x2="26066.213" y2="737.107" gradientTransform="matrix(.0283 0 0 -.0283 248.36 225.244)"><stop offset="0" stop-color="#f8f6ef"/><stop offset="1" stop-color="#e7e4d6"/></linearGradient><path fill="url(#d)" d="M111.713 7.087l532.12 394.525L1179.439 7.087z"/><linearGradient id="e" gradientUnits="userSpaceOnUse" x1="1959.712" y1="737.107" x2="26066.213" y2="737.107" gradientTransform="matrix(.0283 0 0 -.0283 248.36 225.244)"><stop offset="0" stop-color="#f8f6ef"/><stop offset="1" stop-color="#e7e4d6"/></linearGradient><path fill="url(#e)" d="M111.713 7.087l532.12 394.525L1179.439 7.087z"/><linearGradient id="f" gradientUnits="userSpaceOnUse" x1="1959.712" y1="737.107" x2="26066.213" y2="737.107" gradientTransform="matrix(.0283 0 0 -.0283 248.36 225.244)"><stop offset="0" stop-color="#f8f6ef"/><stop offset="1" stop-color="#e7e4d6"/></linearGradient><path fill="url(#f)" d="M111.713 7.087l532.12 394.525L1179.439 7.087z"/><linearGradient id="g" gradientUnits="userSpaceOnUse" x1="1959.712" y1="737.107" x2="26066.213" y2="737.107" gradientTransform="matrix(.0283 0 0 -.0283 248.36 225.244)"><stop offset="0" stop-color="#f8f6ef"/><stop offset="1" stop-color="#e7e4d6"/></linearGradient><path fill="url(#g)" d="M111.713 7.087l532.12 394.525L1179.439 7.087z"/><linearGradient id="h" gradientUnits="userSpaceOnUse" x1="1959.712" y1="737.107" x2="26066.213" y2="737.107" gradientTransform="matrix(.0283 0 0 -.0283 248.36 225.244)"><stop offset="0" stop-color="#f8f6ef"/><stop offset="1" stop-color="#e7e4d6"/></linearGradient><path fill="url(#h)" d="M111.713 7.087l532.12 394.525L1179.439 7.087z"/><path fill="#f7f5ed" d="M111.713 7.087l532.12 394.525L1179.439 7.087z"/></svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

@@ -0,0 +1,38 @@
import * as sdk from '@botpress/sdk'
import {
configuration,
configurations,
identifier,
channels,
user,
states,
actions,
events,
secrets,
} from './definitions'
export const INTEGRATION_NAME = 'gmail'
export const INTEGRATION_VERSION = '1.0.9'
export default new sdk.IntegrationDefinition({
name: INTEGRATION_NAME,
version: INTEGRATION_VERSION,
title: 'Gmail',
description: "Send, receive, and manage emails directly within your bot's workflow.",
icon: 'icon.svg',
readme: 'hub.md',
configuration,
configurations,
identifier,
channels,
user,
actions,
events,
states,
secrets,
attributes: {
category: 'Communication & Channels',
guideSlug: 'gmail',
repo: 'botpress',
},
})
+11
View File
@@ -0,0 +1,11 @@
webhookId = to_string!(.webhookId)
webhookUrl = to_string!(.webhookUrl)
env = to_string!(.env)
clientId = "498444171125-5r2u44kldnvq5ui8skrvjq7a4u0dccnd.apps.googleusercontent.com"
if env == "production" {
clientId = "348984595962-u6at0g0ej8d4ke7ojmepr7osa55jf8qm.apps.googleusercontent.com"
}
"https://accounts.google.com/o/oauth2/v2/auth?scope=https%3A//www.googleapis.com/auth/gmail.send%20https%3A//www.googleapis.com/auth/gmail.readonly&access_type=offline&include_granted_scopes=true&response_type=code&prompt=consent&state={{ webhookId }}&redirect_uri={{ webhookUrl }}/oauth&client_id={{ clientId }}"
+36
View File
@@ -0,0 +1,36 @@
{
"name": "@botpresshub/gmail",
"description": "Gmail integration for Botpress",
"private": true,
"scripts": {
"build": "bp add -y && bp build",
"check:type": "tsc --noEmit",
"check:bplint": "bp lint",
"check:format": "prettier --check .",
"fix:format": "prettier --write .",
"check:lint": "eslint ./ --ext .ts --ext .tsx",
"fix:lint": "eslint --fix ./ --ext .ts --ext .tsx",
"test": "vitest --run"
},
"dependencies": {
"@botpress/cli": "workspace:*",
"@botpress/client": "workspace:*",
"@botpress/common": "workspace:*",
"@botpress/sdk": "workspace:*",
"@botpress/sdk-addons": "workspace:*",
"@react-email/components": "0.0.25",
"gmail-api-parse-message": "^2.1.2",
"googleapis": "^112.0.0",
"node-html-parser": "^6",
"nodemailer": "^6.7.2",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@botpress/cli": "workspace:*",
"@botpress/sdk": "workspace:*",
"@types/nodemailer": "^6.4.4",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.1"
}
}
@@ -0,0 +1,66 @@
import { createActionWrapper, posthogHelper } from '@botpress/common'
import { posthogConfig } from 'src'
import { wrapWithTryCatch } from '../google-api/error-handling'
import { GoogleClient } from '../google-api/google-client'
import * as bp from '.botpress'
export const wrapAction: typeof _wrapAction = (meta, actionImpl) =>
_wrapAction(meta, (props) => {
const startTime = Date.now()
props.logger
.forBot()
.debug(`Running action "${meta.actionName}" [bot id: ${props.ctx.botId}]`, { input: trimInput(props.input) })
return wrapWithTryCatch(() => {
const result = actionImpl(props as Parameters<typeof actionImpl>[0], props.input)
void Promise.resolve(result).then(() => {
const executionTimeMs = Date.now() - startTime
posthogHelper
.sendPosthogEvent(
{
distinctId: props.ctx.integrationId,
event: 'action_executed',
properties: {
actionName: meta.actionName,
botId: props.ctx.botId,
executionTimeMs,
success: true,
},
},
posthogConfig
)
.catch(() => {})
})
return result
}, `Action Error: ${meta.errorMessageWhenFailed}`)()
})
const _wrapAction = createActionWrapper<bp.IntegrationProps>()({
toolFactories: {
googleClient: GoogleClient.create,
},
extraMetadata: {} as {
errorMessageWhenFailed: string
},
})
const MAX_LOG_INPUT_SIZE = 2000
function trimInput(input: unknown): unknown {
try {
const stringified = JSON.stringify(input, null, 2)
if (stringified.length <= MAX_LOG_INPUT_SIZE) {
return input
}
return stringified.substring(0, MAX_LOG_INPUT_SIZE) + '\n... (truncated)'
} catch {
const stringified = String(input)
if (stringified.length <= MAX_LOG_INPUT_SIZE) {
return input
}
return stringified.substring(0, MAX_LOG_INPUT_SIZE) + '... (truncated)'
}
}
@@ -0,0 +1,14 @@
import { GoogleClient } from '../google-api/google-client'
import { wrapAction } from './action-wrapper'
export const changeMessageLabels = wrapAction(
{ actionName: 'changeMessageLabels', errorMessageWhenFailed: 'Failed to change message labels' },
async (
{ googleClient }: { googleClient: Awaited<ReturnType<typeof GoogleClient.create>> },
{ id, addLabelIds, removeLabelIds }: { id: string; addLabelIds?: string[]; removeLabelIds?: string[] }
) => {
await googleClient.messages.modifyLabels(id, addLabelIds, removeLabelIds)
return {}
}
)
@@ -0,0 +1,23 @@
import { GoogleClient } from '../google-api/google-client'
import { wrapAction } from './action-wrapper'
export const createDraft = wrapAction(
{ actionName: 'createDraft', errorMessageWhenFailed: 'Failed to create draft' },
async (
{ googleClient }: { googleClient: Awaited<ReturnType<typeof GoogleClient.create>> },
{ to, subject, body }: { to: string; subject: string; body: string }
) => {
const raw = await googleClient.messages.composeRaw(to, subject, body)
const result = await googleClient.drafts.create(raw)
return {
id: result.id ?? undefined,
message: result.message
? {
id: result.message.id ?? undefined,
threadId: result.message.threadId ?? undefined,
}
: undefined,
}
}
)
@@ -0,0 +1,18 @@
import { GoogleClient } from '../google-api/google-client'
import { wrapAction } from './action-wrapper'
export const createLabel = wrapAction(
{ actionName: 'createLabel', errorMessageWhenFailed: 'Failed to create label' },
async (
{ googleClient }: { googleClient: Awaited<ReturnType<typeof GoogleClient.create>> },
{ name }: { name: string }
) => {
const result = await googleClient.labels.create(name)
return {
id: result.id ?? undefined,
name: result.name ?? undefined,
type: result.type ?? undefined,
}
}
)
@@ -0,0 +1,14 @@
import { GoogleClient } from '../google-api/google-client'
import { wrapAction } from './action-wrapper'
export const deleteDraft = wrapAction(
{ actionName: 'deleteDraft', errorMessageWhenFailed: 'Failed to delete draft' },
async (
{ googleClient }: { googleClient: Awaited<ReturnType<typeof GoogleClient.create>> },
{ id }: { id: string }
) => {
await googleClient.drafts.delete(id)
return {}
}
)
@@ -0,0 +1,14 @@
import { GoogleClient } from '../google-api/google-client'
import { wrapAction } from './action-wrapper'
export const deleteLabel = wrapAction(
{ actionName: 'deleteLabel', errorMessageWhenFailed: 'Failed to delete label' },
async (
{ googleClient }: { googleClient: Awaited<ReturnType<typeof GoogleClient.create>> },
{ id }: { id: string }
) => {
await googleClient.labels.delete(id)
return {}
}
)
@@ -0,0 +1,16 @@
import { GoogleClient } from '../google-api/google-client'
import { wrapAction } from './action-wrapper'
export const deleteMessage = wrapAction(
{ actionName: 'deleteMessage', errorMessageWhenFailed: 'Failed to delete message' },
async (
{ googleClient }: { googleClient: Awaited<ReturnType<typeof GoogleClient.create>> },
{ id }: { id: string }
) => {
const result = await googleClient.messages.delete(id)
return {
result,
}
}
)
@@ -0,0 +1,24 @@
import { GoogleClient } from '../google-api/google-client'
import { wrapAction } from './action-wrapper'
export const getDraft = wrapAction(
{ actionName: 'getDraft', errorMessageWhenFailed: 'Failed to get draft' },
async (
{ googleClient }: { googleClient: Awaited<ReturnType<typeof GoogleClient.create>> },
{ id }: { id: string }
) => {
const result = await googleClient.drafts.get(id)
return {
id: result.id ?? undefined,
message: result.message
? {
id: result.message.id ?? undefined,
threadId: result.message.threadId ?? undefined,
labelIds: result.message.labelIds ?? undefined,
snippet: result.message.snippet ?? undefined,
}
: undefined,
}
}
)
@@ -0,0 +1,30 @@
import { GoogleClient } from '../google-api/google-client'
import { wrapAction } from './action-wrapper'
export const getLabel = wrapAction(
{ actionName: 'getLabel', errorMessageWhenFailed: 'Failed to get label' },
async (
{ googleClient }: { googleClient: Awaited<ReturnType<typeof GoogleClient.create>> },
{ id }: { id: string }
) => {
const result = await googleClient.labels.get(id)
return {
id: result.id ?? undefined,
name: result.name ?? undefined,
type: result.type ?? undefined,
messageListVisibility: result.messageListVisibility ?? undefined,
labelListVisibility: result.labelListVisibility ?? undefined,
messagesTotal: result.messagesTotal ?? undefined,
messagesUnread: result.messagesUnread ?? undefined,
threadsTotal: result.threadsTotal ?? undefined,
threadsUnread: result.threadsUnread ?? undefined,
color: result.color
? {
backgroundColor: result.color.backgroundColor ?? undefined,
textColor: result.color.textColor ?? undefined,
}
: undefined,
}
}
)
@@ -0,0 +1,20 @@
import { GoogleClient } from '../google-api/google-client'
import { wrapAction } from './action-wrapper'
export const getMessageAttachmentFromMail = wrapAction(
{ actionName: 'getMessageAttachmentFromMail', errorMessageWhenFailed: 'Failed to get message attachment from mail' },
async (
{ googleClient }: { googleClient: Awaited<ReturnType<typeof GoogleClient.create>> },
{ messageId }: { messageId: string }
) => {
const attachment = await googleClient.messages.getFirstAttachment(messageId)
return {
attachment: {
size: attachment.size ?? null,
data: attachment.data ?? null,
attachmentId: attachment.attachmentId ?? null,
},
}
}
)
@@ -0,0 +1,20 @@
import { GoogleClient } from '../google-api/google-client'
import { wrapAction } from './action-wrapper'
export const getMessageAttachment = wrapAction(
{ actionName: 'getMessageAttachment', errorMessageWhenFailed: 'Failed to get message attachment' },
async (
{ googleClient }: { googleClient: Awaited<ReturnType<typeof GoogleClient.create>> },
{ messageId, attachmentId }: { messageId: string; attachmentId: string }
) => {
const attachment = await googleClient.messages.getAttachment(messageId, attachmentId)
return {
attachment: {
size: attachment.size ?? null,
data: attachment.data ?? null,
attachmentId: attachment.attachmentId ?? null,
},
}
}
)
@@ -0,0 +1,24 @@
import { GoogleClient } from '../google-api/google-client'
import { wrapAction } from './action-wrapper'
export const getThread = wrapAction(
{ actionName: 'getThread', errorMessageWhenFailed: 'Failed to get thread' },
async (
{ googleClient }: { googleClient: Awaited<ReturnType<typeof GoogleClient.create>> },
{ id }: { id: string }
) => {
const result = await googleClient.threads.get(id)
return {
id: result.id ?? undefined,
snippet: result.snippet ?? undefined,
historyId: result.historyId ?? undefined,
messages: result.messages?.map((message) => ({
id: message.id ?? undefined,
threadId: message.threadId ?? undefined,
labelIds: message.labelIds ?? undefined,
snippet: message.snippet ?? undefined,
})),
}
}
)
+49
View File
@@ -0,0 +1,49 @@
import { changeMessageLabels } from './change-message-labels'
import { createDraft } from './create-draft'
import { createLabel } from './create-label'
import { deleteDraft } from './delete-draft'
import { deleteLabel } from './delete-label'
import { deleteMessage } from './delete-message'
import { getDraft } from './get-draft'
import { getLabel } from './get-label'
import { getMessageAttachment } from './get-message-attachment'
import { getMessageAttachmentFromMail } from './get-message-attachment-from-mail'
import { getThread } from './get-thread'
import { listDrafts } from './list-drafts'
import { listLabels } from './list-labels'
import { listThreads } from './list-threads'
import { sendDraft } from './send-draft'
import { trashMessage } from './trash-message'
import { trashThread } from './trash-thread'
import { untrashMessage } from './untrash-message'
import { untrashThread } from './untrash-thread'
import { updateDraft } from './update-draft'
import { updateLabel } from './update-label'
import * as bp from '.botpress'
export const actions = {
deleteMessage,
trashMessage,
untrashMessage,
changeMessageLabels,
getMessageAttachment,
getMessageAttachmentFromMail,
listThreads,
getThread,
trashThread,
untrashThread,
listLabels,
getLabel,
createLabel,
deleteLabel,
updateLabel,
listDrafts,
getDraft,
createDraft,
deleteDraft,
updateDraft,
sendDraft,
} as const satisfies bp.IntegrationProps['actions']
@@ -0,0 +1,22 @@
import { GoogleClient } from '../google-api/google-client'
import { wrapAction } from './action-wrapper'
export const listDrafts = wrapAction(
{ actionName: 'listDrafts', errorMessageWhenFailed: 'Failed to list drafts' },
async ({ googleClient }: { googleClient: Awaited<ReturnType<typeof GoogleClient.create>> }) => {
const result = await googleClient.drafts.list()
return {
drafts: result.drafts?.map((draft) => ({
id: draft.id ?? undefined,
message: draft.message
? {
id: draft.message.id ?? undefined,
threadId: draft.message.threadId ?? undefined,
}
: undefined,
})),
resultSizeEstimate: result.resultSizeEstimate ?? undefined,
}
}
)
@@ -0,0 +1,17 @@
import { GoogleClient } from '../google-api/google-client'
import { wrapAction } from './action-wrapper'
export const listLabels = wrapAction(
{ actionName: 'listLabels', errorMessageWhenFailed: 'Failed to list labels' },
async ({ googleClient }: { googleClient: Awaited<ReturnType<typeof GoogleClient.create>> }) => {
const result = await googleClient.labels.list()
return {
labels: result.labels?.map((label) => ({
id: label.id ?? undefined,
name: label.name ?? undefined,
type: label.type ?? undefined,
})),
}
}
)
@@ -0,0 +1,18 @@
import { GoogleClient } from '../google-api/google-client'
import { wrapAction } from './action-wrapper'
export const listThreads = wrapAction(
{ actionName: 'listThreads', errorMessageWhenFailed: 'Failed to list threads' },
async ({ googleClient }: { googleClient: Awaited<ReturnType<typeof GoogleClient.create>> }) => {
const result = await googleClient.threads.list()
return {
threads: result.threads?.map((thread) => ({
id: thread.id ?? undefined,
snippet: thread.snippet ?? undefined,
historyId: thread.historyId ?? undefined,
})),
resultSizeEstimate: result.resultSizeEstimate ?? undefined,
}
}
)
@@ -0,0 +1,18 @@
import { GoogleClient } from '../google-api/google-client'
import { wrapAction } from './action-wrapper'
export const sendDraft = wrapAction(
{ actionName: 'sendDraft', errorMessageWhenFailed: 'Failed to send draft' },
async (
{ googleClient }: { googleClient: Awaited<ReturnType<typeof GoogleClient.create>> },
{ id }: { id: string }
) => {
const result = await googleClient.drafts.send(id)
return {
id: result.id ?? undefined,
threadId: result.threadId ?? undefined,
labelIds: result.labelIds ?? undefined,
}
}
)
@@ -0,0 +1,14 @@
import { GoogleClient } from '../google-api/google-client'
import { wrapAction } from './action-wrapper'
export const trashMessage = wrapAction(
{ actionName: 'trashMessage', errorMessageWhenFailed: 'Failed to trash message' },
async (
{ googleClient }: { googleClient: Awaited<ReturnType<typeof GoogleClient.create>> },
{ id }: { id: string }
) => {
await googleClient.messages.trash(id)
return {}
}
)
@@ -0,0 +1,14 @@
import { GoogleClient } from '../google-api/google-client'
import { wrapAction } from './action-wrapper'
export const trashThread = wrapAction(
{ actionName: 'trashThread', errorMessageWhenFailed: 'Failed to trash thread' },
async (
{ googleClient }: { googleClient: Awaited<ReturnType<typeof GoogleClient.create>> },
{ id }: { id: string }
) => {
await googleClient.threads.trash(id)
return {}
}
)
@@ -0,0 +1,14 @@
import { GoogleClient } from '../google-api/google-client'
import { wrapAction } from './action-wrapper'
export const untrashMessage = wrapAction(
{ actionName: 'untrashMessage', errorMessageWhenFailed: 'Failed to untrash message' },
async (
{ googleClient }: { googleClient: Awaited<ReturnType<typeof GoogleClient.create>> },
{ id }: { id: string }
) => {
await googleClient.messages.untrash(id)
return {}
}
)
@@ -0,0 +1,14 @@
import { GoogleClient } from '../google-api/google-client'
import { wrapAction } from './action-wrapper'
export const untrashThread = wrapAction(
{ actionName: 'untrashThread', errorMessageWhenFailed: 'Failed to untrash thread' },
async (
{ googleClient }: { googleClient: Awaited<ReturnType<typeof GoogleClient.create>> },
{ id }: { id: string }
) => {
await googleClient.threads.untrash(id)
return {}
}
)
@@ -0,0 +1,23 @@
import { GoogleClient } from '../google-api/google-client'
import { wrapAction } from './action-wrapper'
export const updateDraft = wrapAction(
{ actionName: 'updateDraft', errorMessageWhenFailed: 'Failed to update draft' },
async (
{ googleClient }: { googleClient: Awaited<ReturnType<typeof GoogleClient.create>> },
{ id, to, subject, body }: { id: string; to: string; subject: string; body: string }
) => {
const raw = await googleClient.messages.composeRaw(to, subject, body)
const result = await googleClient.drafts.update(id, raw)
return {
id: result.id ?? undefined,
message: result.message
? {
id: result.message.id ?? undefined,
threadId: result.message.threadId ?? undefined,
}
: undefined,
}
}
)
@@ -0,0 +1,29 @@
import { GoogleClient } from '../google-api/google-client'
import { wrapAction } from './action-wrapper'
export const updateLabel = wrapAction(
{ actionName: 'updateLabel', errorMessageWhenFailed: 'Failed to update label' },
async (
{ googleClient }: { googleClient: Awaited<ReturnType<typeof GoogleClient.create>> },
{
id,
name,
backgroundColor,
textColor,
}: { id: string; name?: string; backgroundColor?: string; textColor?: string }
) => {
const result = await googleClient.labels.update(id, name, backgroundColor, textColor)
return {
id: result.id ?? undefined,
name: result.name ?? undefined,
type: result.type ?? undefined,
color: result.color
? {
backgroundColor: result.color.backgroundColor ?? undefined,
textColor: result.color.textColor ?? undefined,
}
: undefined,
}
}
)
@@ -0,0 +1,38 @@
import { createChannelWrapper } from '@botpress/common'
import * as sdk from '@botpress/sdk'
import { GoogleClient, wrapWithTryCatch } from '../google-api'
import * as bp from '.botpress'
export const wrapChannel: typeof _injectTools = (meta, channelImpl) =>
_injectTools(meta, (props) =>
wrapWithTryCatch(() => {
props.logger
.forBot()
.debug(
`Sending message of type "${meta.messageType}" on channel "${meta.channelName}" for bot "${props.ctx.botId}"`
)
return channelImpl(props as Parameters<typeof channelImpl>[0])
}, `Unable to send message of type "${meta.messageType}" on channel "${meta.channelName}"`)()
)
const _injectTools = createChannelWrapper<bp.IntegrationProps>()({
toolFactories: {
async googleClient({ client, ctx }) {
return await GoogleClient.create({ client, ctx })
},
async inReplyTo({ client, conversation }) {
const { state } = await client.getState({
type: 'conversation',
name: 'thread',
id: conversation.id,
})
if (!state.payload.inReplyTo) {
throw new sdk.RuntimeError('Attempting to reply to a message, but no inReplyTo tag was found')
}
return state.payload.inReplyTo
},
},
})
+187
View File
@@ -0,0 +1,187 @@
import * as sdk from '@botpress/sdk'
import { RuntimeError } from '@botpress/sdk'
import { GoogleClient } from '../google-api'
import {
composeRawEmail,
generateAudioMessage,
generateCardMessage,
generateCarouselMessage,
generateFileDownloadMessage,
generateImageMessage,
generateLocationMessage,
generateMarkdownMessage,
generateVideoMessage,
} from '../utils/mail-composing'
import { wrapChannel } from './channel-wrapper'
import * as bp from '.botpress'
export const channels = {
channel: {
messages: {
image: wrapChannel({ channelName: 'channel', messageType: 'image' }, (props) => {
const { imageUrl, title: altText } = props.payload
const htmlContent = generateImageMessage({ imageUrl, altText: altText ?? 'image' })
const textContent = `Image:\n${imageUrl}`
return _sendEmailReply({
...props,
textContent,
htmlContent,
})
}),
audio: wrapChannel({ channelName: 'channel', messageType: 'audio' }, (props) => {
const { audioUrl, title } = props.payload
const htmlContent = generateAudioMessage({ audioUrl, title: title ?? 'Play audio file' })
const textContent = `Audio file:\n${audioUrl}`
return _sendEmailReply({
...props,
textContent,
htmlContent,
})
}),
video: wrapChannel({ channelName: 'channel', messageType: 'video' }, (props) => {
const { videoUrl, title } = props.payload
const htmlContent = generateVideoMessage({ videoUrl, title: title ?? 'Play video file' })
const textContent = `Video file:\n${videoUrl}`
return _sendEmailReply({
...props,
textContent,
htmlContent,
})
}),
file: wrapChannel({ channelName: 'channel', messageType: 'file' }, (props) => {
const { fileUrl, title } = props.payload
const htmlContent = generateFileDownloadMessage({ fileUrl, title: title ?? 'Download file' })
const textContent = `Linked file:\n${fileUrl}`
return _sendEmailReply({
...props,
textContent,
htmlContent,
})
}),
text: wrapChannel({ channelName: 'channel', messageType: 'text' }, async (props) => {
const { text: textContent } = props.payload
await _sendEmailReply({
...props,
textContent,
})
}),
choice: wrapChannel({ channelName: 'channel', messageType: 'choice' }, async (props) => {
const { text, options } = props.payload
let content = `${text}\n`
for (const option of options) {
content += `- ${option.label}\n`
}
await _sendEmailReply({
...props,
textContent: content,
})
}),
markdown: wrapChannel({ channelName: 'channel', messageType: 'markdown' }, async (props) => {
const { markdown } = props.payload
const htmlContent = generateMarkdownMessage({ markdown })
const textContent = markdown
await _sendEmailReply({
...props,
textContent,
htmlContent,
})
}),
location: wrapChannel({ channelName: 'channel', messageType: 'location' }, async (props) => {
const { latitude, longitude, address, title } = props.payload
const htmlContent = generateLocationMessage({ latitude, longitude, address, title })
const textContent = `https://www.google.com/maps/search/?api=1&query=${latitude},${longitude}`
await _sendEmailReply({
...props,
textContent,
htmlContent,
})
}),
card: wrapChannel({ channelName: 'channel', messageType: 'card' }, async (props) => {
const { title, subtitle, imageUrl, actions } = props.payload
const htmlContent = generateCardMessage({ title, subtitle: subtitle ?? '', imageUrl: imageUrl ?? '', actions })
const textContent = `${title}\n${subtitle}\n\n${actions.map((a) => `${a.label}: ${a.value}`).join('\n')}`
await _sendEmailReply({
...props,
textContent,
htmlContent,
})
}),
carousel: wrapChannel({ channelName: 'channel', messageType: 'carousel' }, async (props) => {
const { items: cards } = props.payload
const htmlContent = generateCarouselMessage({
cards: cards.map((c) => ({ ...c, subtitle: c.subtitle ?? '', imageUrl: c.imageUrl ?? '' })),
})
const textContent = cards
.map((c) => `${c.title}\n${c.subtitle}\n\n${c.actions.map((a) => `${a.label}: ${a.value}`).join('\n')}`)
.join('\n\n\n')
await _sendEmailReply({
...props,
textContent,
htmlContent,
})
}),
dropdown: wrapChannel({ channelName: 'channel', messageType: 'dropdown' }, () => {
throw new sdk.RuntimeError('This message type is not yet implemented')
}),
bloc: wrapChannel({ channelName: 'channel', messageType: 'bloc' }, () => {
throw new sdk.RuntimeError('This message type is not yet implemented')
}),
},
},
} as const satisfies bp.IntegrationProps['channels']
const _sendEmailReply = async ({
conversation,
ack,
textContent,
htmlContent,
inReplyTo,
googleClient,
}: bp.AnyMessageProps & {
logger: bp.Logger
textContent: string
htmlContent?: string
inReplyTo: string
googleClient: GoogleClient
}) => {
const { threadId, email, subject, references, cc } = _getConversationInfo(conversation)
const raw = await composeRawEmail({
to: email,
subject,
text: textContent,
html: htmlContent ?? textContent,
textEncoding: 'base64',
inReplyTo,
references: references ?? inReplyTo,
cc,
})
const res = await googleClient.messages.send(raw, threadId)
await ack({ tags: { id: `${res.id}` } })
}
const _getConversationInfo = (conversation: bp.AnyMessageProps['conversation']) => {
const { id, tags } = conversation
const { id: threadId, subject, email, references, cc } = tags
if (!(threadId && subject && email)) {
throw new RuntimeError(`No valid information found for conversation ${id}`)
}
return { threadId, subject, email, references, cc }
}
+1
View File
@@ -0,0 +1 @@
export * from './channels'
@@ -0,0 +1,31 @@
import * as bp from '.botpress'
export namespace IntegrationConfig {
export const getOAuthConfig = ({ ctx }: { ctx: bp.Context }) => ({
clientId: _getOAuthClientId({ ctx }),
clientSecret: _getOAuthClientSecret({ ctx }),
endpoint: _getOAuthEndpoint({ ctx }),
})
export const getPubSubTopicName = ({ ctx }: { ctx: bp.Context }) =>
ctx.configurationType === 'customApp' ? ctx.configuration.pubsubTopicName : bp.secrets.TOPIC_NAME
export const getPubSubWebhookSharedSecret = ({ ctx }: { ctx: bp.Context }) =>
ctx.configurationType === 'customApp'
? ctx.configuration.pubsubWebhookSharedSecret
: bp.secrets.WEBHOOK_SHARED_SECRET
export const getPubSubWebhookServiceAccount = ({ ctx }: { ctx: bp.Context }) =>
ctx.configurationType === 'customApp'
? ctx.configuration.pubsubWebhookServiceAccount
: bp.secrets.WEBHOOK_SERVICE_ACCOUNT
const _getOAuthEndpoint = ({ ctx }: { ctx: bp.Context }) =>
ctx.configurationType === 'customApp' ? 'https://botpress.com' : `${process.env.BP_WEBHOOK_URL}/oauth`
const _getOAuthClientId = ({ ctx }: { ctx: bp.Context }) =>
ctx.configurationType === 'customApp' ? ctx.configuration.oauthClientId : bp.secrets.CLIENT_ID
const _getOAuthClientSecret = ({ ctx }: { ctx: bp.Context }) =>
ctx.configurationType === 'customApp' ? ctx.configuration.oauthClientSecret : bp.secrets.CLIENT_SECRET
}
@@ -0,0 +1,70 @@
import { isApiError } from '@botpress/client'
import { createAsyncFnWrapperWithErrorRedaction, createErrorHandlingDecorator, posthogHelper } from '@botpress/common'
import * as sdk from '@botpress/sdk'
import { Common as GoogleApisCommon } from 'googleapis'
import { INTEGRATION_NAME, INTEGRATION_VERSION } from 'integration.definition'
import * as bp from '.botpress'
export const wrapAsyncFnWithTryCatch = createAsyncFnWrapperWithErrorRedaction((error: Error, customMessage: string) => {
if (error instanceof sdk.RuntimeError) {
return error
}
const googleError = _extractGoogleApiError(error)
const redactedMessage = googleError ? `${customMessage}: ${googleError}` : customMessage
const errorMessage = error.message || String(error)
const distinctId = isApiError(error) ? error.id : undefined
const statusCode = _isGaxiosError(error) ? error.response?.status : undefined
const errorReason = _isGaxiosError(error) ? error.response?.statusText : undefined
posthogHelper
.sendPosthogEvent(
{
distinctId: distinctId ?? 'no id',
event: 'api_error',
properties: {
from: 'gmail_client',
errorMessage: customMessage,
googleError: googleError?.substring(0, 200) || errorMessage.substring(0, 200),
statusCode: statusCode?.toString(),
errorReason: errorReason?.substring(0, 100),
},
},
{ integrationName: INTEGRATION_NAME, integrationVersion: INTEGRATION_VERSION, key: bp.secrets.POSTHOG_KEY }
)
.catch(() => {
// Silently fail if PostHog is unavailable
})
return new sdk.RuntimeError(redactedMessage)
})
export const handleErrorsDecorator = createErrorHandlingDecorator(wrapAsyncFnWithTryCatch)
/*
Since emails can be quite sensitive, by default we will not expose the
original error message to the user, instead replacing it with a generic
message if we're not given a RuntimeError. This is done by the default
error redactor function.
*/
export const wrapWithTryCatch = createAsyncFnWrapperWithErrorRedaction((error: Error, customMessage: string) => {
if (error instanceof sdk.RuntimeError) {
return error
}
// For action/channel wrappers, we use a more generic error message
return new sdk.RuntimeError(customMessage)
})
const _extractGoogleApiError = (error: Error) =>
_isGaxiosError(error)
? error['errors']
.map((err: { message: string }) => err.message)
.join(', ')
.replaceAll(/Invalid requests\[0\].[a-zA-Z]+:/g, '')
: null
type AggregateGAxiosError = GoogleApisCommon.GaxiosError & { errors: Error[] }
const _isGaxiosError = (error: Error): error is AggregateGAxiosError =>
'errors' in error && Array.isArray(error['errors'])
@@ -0,0 +1,393 @@
import * as sdk from '@botpress/sdk'
import { gmail_v1, google } from 'googleapis'
import { IntegrationConfig } from 'src/config/integration-config'
import { composeRawEmail } from 'src/utils/mail-composing'
import { handleErrorsDecorator as handleErrors } from './error-handling'
import { GmailClient, GoogleOAuth2Client } from './types'
import * as bp from '.botpress'
export class GoogleClient {
public readonly threads: ThreadManagement
public readonly messages: MessageManagement
public readonly labels: LabelManagement
public readonly drafts: DraftManagement
private constructor(
private readonly _gmail: GmailClient,
private readonly _topicName: string
) {
this.threads = new ThreadManagement(this._gmail)
this.messages = new MessageManagement(this._gmail)
this.labels = new LabelManagement(this._gmail)
this.drafts = new DraftManagement(this._gmail)
}
public static async create({
client,
ctx,
refreshToken,
}: {
client: bp.Client
ctx: bp.Context
refreshToken?: string
}) {
const token = refreshToken ?? (await GoogleClient._getRefreshTokenFromStates({ client, ctx }))
if (!token) {
throw new sdk.RuntimeError('No refresh token found. Please complete the OAuth flow to obtain a refresh token.')
}
const oauth2Client = GoogleClient._getOAuthClient({ ctx })
try {
oauth2Client.setCredentials({ refresh_token: token })
} catch (error) {
throw new sdk.RuntimeError(`Failed to set OAuth credentials: ${error}`)
}
const gmailClient = google.gmail({
version: 'v1',
auth: oauth2Client,
timeout: 30000,
})
const topicName = IntegrationConfig.getPubSubTopicName({ ctx })
return new GoogleClient(gmailClient, topicName)
}
@handleErrors('Failed to obtain Gmail OAuth refresh token from authorization code')
public static async createFromAuthorizationCode({
client,
ctx,
authorizationCode,
}: {
client: bp.Client
ctx: bp.Context
authorizationCode: string
}) {
const refreshToken = await GoogleClient._exchangeAuthorizationCodeForRefreshToken({ ctx, authorizationCode })
await GoogleClient._saveRefreshTokenIntoStates({ client, ctx, refreshToken, authorizationCode })
return GoogleClient.create({ client, ctx, refreshToken })
}
public async watchIncomingMail() {
return this._gmail.users.watch({
userId: 'me',
requestBody: { topicName: this._topicName },
})
}
public async getMyEmail() {
const profile = await this._gmail.users.getProfile({
userId: 'me',
})
return profile.data.emailAddress
}
public async getMyHistory(startHistoryId?: string) {
const history = await this._gmail.users.history.list({
startHistoryId,
historyTypes: ['messageAdded'],
userId: 'me',
})
return history.data
}
private static async _getRefreshTokenFromStates({ client, ctx }: { client: bp.Client; ctx: bp.Context }) {
try {
const { state } = await client.getOrSetState({
type: 'integration',
name: 'configuration',
id: ctx.integrationId,
payload: {
refreshToken: '',
lastHistoryId: undefined,
},
})
const refreshToken = state.payload.refreshToken || undefined
return refreshToken
} catch (error) {
throw new sdk.RuntimeError(`Failed to get refresh token from states: ${error}`)
}
}
private static async _saveRefreshTokenIntoStates({
client,
ctx,
refreshToken,
authorizationCode,
}: {
client: bp.Client
ctx: bp.Context
refreshToken: string
authorizationCode?: string
}) {
// Use patchState to preserve existing fields like lastHistoryId
try {
await client.patchState({
type: 'integration',
name: 'configuration',
id: ctx.integrationId,
payload: {
refreshToken,
...(authorizationCode && { authorizationCode }),
},
})
} catch {
// If state doesn't exist, create it with getOrSetState
await client.getOrSetState({
type: 'integration',
name: 'configuration',
id: ctx.integrationId,
payload: {
refreshToken,
lastHistoryId: undefined,
...(authorizationCode && { authorizationCode }),
},
})
}
}
private static async _exchangeAuthorizationCodeForRefreshToken({
ctx,
authorizationCode,
}: {
ctx: bp.Context
authorizationCode: string
}) {
const refreshToken = await GoogleClient._getRefreshToken({ ctx, authorizationCode })
if (!refreshToken) {
throw new sdk.RuntimeError('Unable to obtain refresh token. Please try the OAuth flow again.')
}
return refreshToken
}
private static async _getRefreshToken({ ctx, authorizationCode }: { ctx: bp.Context; authorizationCode: string }) {
const oauth2Client = GoogleClient._getOAuthClient({ ctx })
try {
const response = await oauth2Client.getToken(authorizationCode)
return response.tokens.refresh_token ?? null
} catch (thrown) {
GoogleClient._handleRefreshTokenError({ ctx, thrown })
}
return null
}
private static _handleRefreshTokenError({ ctx, thrown }: { ctx: bp.Context; thrown: unknown }) {
console.error('Error exchanging authorization code for refresh token', thrown)
if (ctx.configurationType === 'customApp') {
throw new sdk.RuntimeError(
'Unable to exchange authorization code for refresh token: this may be due to an expired authorization code.' +
'Please try the OAuth flow again and update the integration settings with the new authorization code.'
)
}
}
private static _getOAuthClient({ ctx }: { ctx: bp.Context }): GoogleOAuth2Client {
const { clientId, clientSecret, endpoint } = IntegrationConfig.getOAuthConfig({ ctx })
return new google.auth.OAuth2(clientId, clientSecret, endpoint)
}
}
class MessageManagement {
public constructor(private readonly _gmail: GmailClient) {}
public async get(messageId: string) {
const message = await this._gmail.users.messages.get({ id: messageId, userId: 'me' })
return message.data
}
public async send(raw: string, threadId?: string) {
const newMail = await this._gmail.users.messages.send({ requestBody: { raw, threadId }, userId: 'me' })
return newMail.data
}
public async delete(messageId: string) {
await this._gmail.users.messages.delete({ id: messageId, userId: 'me' })
}
public async trash(messageId: string) {
await this._gmail.users.messages.trash({ id: messageId, userId: 'me' })
}
public async untrash(messageId: string) {
await this._gmail.users.messages.untrash({ id: messageId, userId: 'me' })
}
public async modifyLabels(messageId: string, addLabelIds?: string[], removeLabelIds?: string[]) {
await this._gmail.users.messages.modify({
id: messageId,
userId: 'me',
requestBody: { addLabelIds, removeLabelIds },
})
}
public async getAttachment(messageId?: string, attachmentId?: string) {
const attachment = await this._gmail.users.messages.attachments.get({ id: attachmentId, messageId, userId: 'me' })
return attachment.data
}
public async getFirstAttachment(messageId: string) {
const message = await this.get(messageId)
const attachmentId = this._findFirstAttachmentId(message.payload)
if (!attachmentId) {
throw new sdk.RuntimeError('No attachment found in the message')
}
const attachment = await this._gmail.users.messages.attachments.get({
id: attachmentId,
messageId,
userId: 'me',
})
return {
...attachment.data,
attachmentId,
}
}
public async composeRaw(to: string, subject: string, body: string) {
const raw = await composeRawEmail({
to,
subject,
text: body,
html: body,
textEncoding: 'base64',
})
return raw
}
private _findFirstAttachmentId(payload: gmail_v1.Schema$MessagePart | undefined): string | null {
if (!payload) {
return null
}
if (payload.body?.attachmentId) {
return payload.body.attachmentId
}
if (payload.parts && Array.isArray(payload.parts)) {
for (const part of payload.parts) {
const attachmentId = this._findFirstAttachmentId(part)
if (attachmentId) {
return attachmentId
}
}
}
return null
}
}
class ThreadManagement {
public constructor(private readonly _gmail: GmailClient) {}
public async list() {
const threads = await this._gmail.users.threads.list({ userId: 'me' })
return threads.data
}
public async get(threadId: string) {
const thread = await this._gmail.users.threads.get({ id: threadId, userId: 'me' })
return thread.data
}
public async trash(threadId: string) {
await this._gmail.users.threads.trash({ id: threadId, userId: 'me' })
}
public async untrash(threadId: string) {
await this._gmail.users.threads.untrash({ id: threadId, userId: 'me' })
}
}
class LabelManagement {
public constructor(private readonly _gmail: GmailClient) {}
public async list() {
const labels = await this._gmail.users.labels.list({ userId: 'me' })
return labels.data
}
public async get(labelId: string) {
const label = await this._gmail.users.labels.get({ id: labelId, userId: 'me' })
return label.data
}
public async create(name: string) {
const label = await this._gmail.users.labels.create({ requestBody: { name }, userId: 'me' })
return label.data
}
public async delete(labelId: string) {
await this._gmail.users.labels.delete({ id: labelId, userId: 'me' })
}
public async update(labelId: string, name?: string, backgroundColor?: string, textColor?: string) {
const label = await this._gmail.users.labels.update({
id: labelId,
userId: 'me',
requestBody: {
name,
color: backgroundColor || textColor ? { backgroundColor, textColor } : undefined,
},
})
return label.data
}
}
class DraftManagement {
public constructor(private readonly _gmail: GmailClient) {}
public async list() {
const drafts = await this._gmail.users.drafts.list({ userId: 'me' })
return drafts.data
}
public async get(draftId: string) {
const draft = await this._gmail.users.drafts.get({ id: draftId, userId: 'me' })
return draft.data
}
public async create(raw: string) {
const draft = await this._gmail.users.drafts.create({
userId: 'me',
requestBody: { message: { raw } },
})
return draft.data
}
public async delete(draftId: string) {
await this._gmail.users.drafts.delete({ id: draftId, userId: 'me' })
}
public async update(draftId: string, raw: string) {
const draft = await this._gmail.users.drafts.update({
id: draftId,
userId: 'me',
requestBody: { message: { raw } },
})
return draft.data
}
public async send(draftId: string) {
const sent = await this._gmail.users.drafts.send({
userId: 'me',
requestBody: { id: draftId },
})
return sent.data
}
}
@@ -0,0 +1,3 @@
export * from './error-handling'
export * from './google-client'
export * from './jwt-validation'
@@ -0,0 +1,99 @@
import { google } from 'googleapis'
import { IntegrationConfig } from 'src/config/integration-config'
import { MS_IN_12_HOURS } from 'src/utils/datetime-utils'
import { GoogleOAuth2Client, GoogleCerts, LoginTicketPayload } from './types'
import * as bp from '.botpress'
const CERT_CACHE_STATE_NAME = 'googlePublicCertCache'
const ALLOWED_ISSUERS = ['https://accounts.google.com']
export class JWTVerifier {
private readonly _oauth2Client: GoogleOAuth2Client
private readonly _client: bp.Client
private readonly _ctx: bp.Context
private readonly _logger: bp.Logger
public constructor({ ctx, client, logger }: { ctx: bp.Context; client: bp.Client; logger: bp.Logger }) {
this._oauth2Client = new google.auth.OAuth2()
this._client = client
this._ctx = ctx
this._logger = logger
}
public async isJWTProperlySigned(token: string): Promise<boolean> {
try {
return await this._verifyJWTSignature(token)
} catch (thrown: unknown) {
console.error("Couldn't verify JWT signature", thrown)
return false
}
}
private async _verifyJWTSignature(token: string): Promise<boolean> {
const certificates = await this._getCertificates()
const loginTicketPayload = await this._getLoginTicketPayload(certificates, token)
return this._validatePayload(loginTicketPayload)
}
private async _getCertificates(): Promise<GoogleCerts> {
const cachedCerts = await this._getCachedCertificates()
return cachedCerts ?? (await this._fetchAndCacheCertificates())
}
private async _getLoginTicketPayload(certificates: GoogleCerts, token: string): Promise<LoginTicketPayload> {
const requiredAudience = IntegrationConfig.getPubSubWebhookSharedSecret({ ctx: this._ctx })
const loginTicket = await this._oauth2Client.verifySignedJwtWithCertsAsync(
token,
certificates,
requiredAudience,
ALLOWED_ISSUERS
)
return loginTicket.getPayload()
}
private _validatePayload(payload: LoginTicketPayload): boolean {
const requiredRecipient = IntegrationConfig.getPubSubWebhookServiceAccount({ ctx: this._ctx })
if (!payload?.email_verified || payload.email !== requiredRecipient) {
console.error('Invalid or unverified email in JWT payload', payload?.email)
this._logger
.forBot()
.error(
'Received a webhook event with an invalid service account. Please ensure that the service account used is the same one configured in the integration settings.'
)
return false
}
return true
}
private async _getCachedCertificates(): Promise<GoogleCerts | null> {
try {
const { state } = await this._client.getState({
type: 'integration',
name: CERT_CACHE_STATE_NAME,
id: this._ctx.integrationId,
})
return JSON.parse(state.payload.certificates)
} catch {
return null
}
}
private async _fetchAndCacheCertificates(): Promise<GoogleCerts> {
const { certs } = await this._oauth2Client.getFederatedSignonCertsAsync()
await this._client.getOrSetState({
name: CERT_CACHE_STATE_NAME,
type: 'integration',
expiry: MS_IN_12_HOURS,
id: this._ctx.integrationId,
payload: { certificates: JSON.stringify(certs) },
})
return certs
}
}
@@ -0,0 +1,8 @@
import { google } from 'googleapis'
export type GmailClient = ReturnType<typeof google.gmail>
export type GoogleOAuth2Client = InstanceType<(typeof google.auth)['OAuth2']>
export type GoogleCerts = Awaited<ReturnType<GoogleOAuth2Client['getFederatedSignonCertsAsync']>>['certs']
export type LoginTicketPayload = ReturnType<
Awaited<ReturnType<GoogleOAuth2Client['verifySignedJwtWithCertsAsync']>>['getPayload']
>
+22
View File
@@ -0,0 +1,22 @@
import { posthogHelper } from '@botpress/common'
import { INTEGRATION_NAME, INTEGRATION_VERSION } from 'integration.definition'
import { actions } from './actions'
import { channels } from './channels'
import { register, unregister } from './setup'
import { handler } from './webhook-events'
import * as bp from '.botpress'
export const posthogConfig: posthogHelper.PostHogConfig = {
integrationName: INTEGRATION_NAME,
key: bp.secrets.POSTHOG_KEY,
integrationVersion: INTEGRATION_VERSION,
}
const integrationConfig: bp.IntegrationProps = {
register,
unregister,
actions,
channels,
handler,
}
export default posthogHelper.wrapIntegration(posthogConfig, integrationConfig)
+51
View File
@@ -0,0 +1,51 @@
import { GoogleClient } from './google-api'
import * as bp from '.botpress'
export const register: bp.IntegrationProps['register'] = async ({ client, ctx, logger }) => {
let googleClient: GoogleClient
const createFromRefreshToken = async () => {
try {
return await GoogleClient.create({ client, ctx })
} catch (err) {
logger.forBot().error({ err }, 'Failed to create Google client from refresh token')
throw err
}
}
if (ctx.configurationType !== 'customApp') {
logger.forBot().info('Using refresh token from configuration')
googleClient = await createFromRefreshToken()
} else {
if (!ctx.configuration.oauthAuthorizationCode) {
logger.forBot().info('No authorization code provided, using existing refresh token from state')
googleClient = await createFromRefreshToken()
} else {
logger.forBot().info('Using authorization code from context')
try {
googleClient = await GoogleClient.createFromAuthorizationCode({
client,
ctx,
authorizationCode: ctx.configuration.oauthAuthorizationCode,
})
logger.forBot().info('Successfully created Google client from authorization code')
} catch (err) {
logger.forBot().warn({ err }, 'Failed to create Google client from authorization code; falling back')
googleClient = await createFromRefreshToken()
}
}
}
logger.forBot().info('Setting up Gmail watch for incoming emails...')
try {
await googleClient
.watchIncomingMail()
.catch((error) =>
logger.forBot().warn(`Failed to set up Gmail watch: ${error instanceof Error ? error.message : String(error)}`)
)
} catch (error) {
logger.forBot().error(`Failed to set up Gmail watch ${error}`)
}
}
export const unregister: bp.IntegrationProps['unregister'] = async () => {}
@@ -0,0 +1 @@
export const MS_IN_12_HOURS = 43_200_000 as const
@@ -0,0 +1,261 @@
import {
Body,
Container,
Head,
Html,
Img,
Link,
Button,
Row,
Column,
Heading,
Markdown,
Text,
Section,
} from '@react-email/components'
import MailComposer from 'nodemailer/lib/mail-composer'
import type Mail from 'nodemailer/lib/mailer'
import * as react from 'react'
import { renderToString } from 'react-dom/server'
import { encodeBase64URL } from './string-utils'
export const composeRawEmail = async (options: Mail.Options) => {
const mailComposer = new MailComposer(options)
const message = await mailComposer.compile().build()
return encodeBase64URL(message)
}
export const generateImageMessage = (props: Parameters<typeof _ImageMessage>[0]) => _renderMessage(_ImageMessage(props))
export const generateAudioMessage = (props: Parameters<typeof _AudioMessage>[0]) => _renderMessage(_AudioMessage(props))
export const generateVideoMessage = (props: Parameters<typeof _VideoMessage>[0]) => _renderMessage(_VideoMessage(props))
export const generateFileDownloadMessage = (props: Parameters<typeof _FileDownloadMessage>[0]) =>
_renderMessage(_FileDownloadMessage(props))
export const generateMarkdownMessage = ({ markdown }: { markdown: string }) =>
_renderMessage(<Markdown>{markdown}</Markdown>)
export const generateCardMessage = (props: Parameters<typeof _CardMessage>[0]) => _renderMessage(_CardMessage(props))
export const generateCarouselMessage = (props: Parameters<typeof _CarouselMessage>[0]) =>
_renderMessage(_CarouselMessage(props))
export const generateLocationMessage = (props: Parameters<typeof _LocationMessage>[0]) =>
_renderMessage(_LocationMessage(props))
const _renderMessage = (message: react.ReactNode) => renderToString(<_BaseMessage>{message}</_BaseMessage>)
const _BaseMessage = ({ children }: react.PropsWithChildren) => (
<Html>
<Head />
<Body style={_bodyStyle}>
<Container style={_innerContainerStyle}>{children}</Container>
</Body>
</Html>
)
const _bodyStyle = {
backgroundColor: '#ffffff',
} as const
const _innerContainerStyle = {
paddingLeft: '12px',
paddingRight: '12px',
margin: '0 auto',
} as const
const _primaryButtonStyle = {
backgroundColor: 'rgb(79,70,229)',
borderRadius: '8px',
paddingLeft: '40px',
paddingRight: '40px',
paddingTop: '12px',
paddingBottom: '12px',
fontWeight: '600',
color: 'rgb(255,255,255)',
} as const
const _primaryHeadingStyle = {
fontSize: '24px',
lineHeight: '36px',
fontWeight: 600,
color: 'rgb(17,24,39)',
textAlign: 'center',
} as const
const _ImageMessage = ({ imageUrl, altText }: { imageUrl: string; altText: string }) => (
<>
<Heading as="h1" style={_primaryHeadingStyle}>
{altText}
</Heading>
<Link href={imageUrl}>
<Img alt={altText} height={250} src={imageUrl} style={{ borderRadius: 12, margin: '0 auto' }} />
</Link>
</>
)
const _IconButton = ({ icon, text, href }: { icon: react.ReactNode; text: string; href: string }) => (
<Button href={href} style={_primaryButtonStyle}>
<Row>
<Column style={{ paddingRight: '.3em' }} role="presentation">
{icon}
</Column>
<Column>{text}</Column>
</Row>
</Button>
)
const _AudioMessage = ({ audioUrl, title: text }: { audioUrl: string; title: string }) =>
_IconButton({ icon: <span style={{ fontSize: '1.3em', lineHeight: '100%' }}></span>, text, href: audioUrl })
const _VideoMessage = ({ videoUrl, title: text }: { videoUrl: string; title: string }) =>
_IconButton({ icon: <span style={{ fontSize: '1.3em', lineHeight: '100%' }}></span>, text, href: videoUrl })
const _FileDownloadMessage = ({ fileUrl, title: text }: { fileUrl: string; title: string }) =>
_IconButton({ icon: '🡳', text, href: fileUrl })
const _LocationMessage = ({
latitude,
longitude,
address,
title,
}: {
latitude: number
longitude: number
address?: string
title?: string
}) => {
const previewUrl = `https://staticmap.maptoolkit.net/?size=350x250&zoom=16&marker=center:${latitude},${longitude}`
const googleMapsUrl = `https://www.google.com/maps/search/?api=1&query=${address ?? `${latitude},${longitude}`}`
return (
<>
{title && (
<Heading as="h1" style={_primaryHeadingStyle}>
{title}
</Heading>
)}
<Link href={googleMapsUrl}>
<Img alt={address} height={250} width={350} src={previewUrl} style={{ borderRadius: 12, margin: '0 auto' }} />
</Link>
{address && <Text style={{ textAlign: 'center' }}>{address}</Text>}
<Container style={{ marginTop: '1em', textAlign: 'center' }}>
<Button href={googleMapsUrl} style={_primaryButtonStyle}>
Open in Google Maps
</Button>
</Container>
</>
)
}
const _CardMessage = ({
title,
subtitle,
imageUrl,
actions,
}: {
title: string
subtitle: string
imageUrl: string
actions: {
action: 'postback' | 'url' | 'say'
label: string
value: string
}[]
}) => (
<Container>
<Img
alt="banner image"
height="320"
src={imageUrl}
style={{
width: '100%',
borderRadius: 12,
objectFit: 'cover',
}}
role="presentation"
/>
<Section
style={{
marginTop: 24,
textAlign: 'center',
}}
>
<Text
style={{
marginTop: 16,
marginBottom: 16,
fontSize: 18,
lineHeight: '28px',
fontWeight: 600,
color: 'rgb(79,70,229)',
}}
>
{subtitle}
</Text>
<Heading
as="h1"
style={{
margin: '0px',
marginTop: 8,
marginBottom: 16,
fontSize: 36,
lineHeight: '36px',
fontWeight: 600,
color: 'rgb(17,24,39)',
}}
>
{title}
</Heading>
<table
style={{
width: 'auto',
margin: '0 auto',
}}
>
{actions.map(({ label, value }) => (
<tr>
<td>
<Button
href={value}
style={{
marginTop: 16,
borderRadius: 8,
backgroundColor: 'rgb(79,70,229)',
paddingLeft: 40,
paddingRight: 40,
paddingTop: 12,
paddingBottom: 12,
fontWeight: 600,
color: 'rgb(255,255,255)',
display: 'block',
}}
>
{label}
</Button>
</td>
</tr>
))}
</table>
</Section>
</Container>
)
const _CarouselMessage = ({
cards,
}: {
cards: {
title: string
subtitle: string
imageUrl: string
actions: { action: 'postback' | 'url' | 'say'; label: string; value: string }[]
}[]
}) => (
<>
{cards.map((cardProps) => (
<div style={{ marginBottom: '6em' }}>{_CardMessage(cardProps)}</div>
))}
</>
)
@@ -0,0 +1,3 @@
export const encodeBase64URL = (str: string | Buffer) => Buffer.from(str).toString('base64url')
export const decodeBase64URL = (str: string) => Buffer.from(str, 'base64url').toString()
@@ -0,0 +1,71 @@
import * as sdk from '@botpress/sdk'
import { IntegrationConfig } from 'src/config/integration-config'
import { JWTVerifier } from 'src/google-api'
import { handleIncomingEmail } from './new-mail'
import { handleOAuthCallback } from './oauth-callback'
import * as bp from '.botpress'
export const handler = async (props: bp.HandlerProps) => {
const { logger } = props
if (props.req.path.startsWith('/oauth')) {
logger.forBot().info('Processing OAuth callback')
return handleOAuthCallback(props)
}
logger.forBot().info('Authenticating webhook...')
if (!(await _isWebhookEventProperlyAuthenticated(props))) {
logger.forBot().error('Webhook authentication failed')
throw new sdk.RuntimeError(`Incoming webhook event is not properly authenticated ${props.req}`)
}
await handleIncomingEmail(props)
return {}
}
/*
NOTE: the JWT validation process can be somewhat expensive, because we have to
fetch Google's public certs and cache them. This means we'd rather not
perform this validation for bogus request, so we short-circuit the
validation process by first checking whether a specific string is
present in the query parameters, as suggested by Google. On subsequent
non-bogus webhook events, validation is quicker though because we use
the cert cache rather than poking Google every time for its certs.
*/
const _isWebhookEventProperlyAuthenticated = async (props: bp.HandlerProps) =>
_isSharedSecretValid(props) && (await _isJWTValid(props))
const _isSharedSecretValid = ({ req, ctx, logger }: bp.HandlerProps) => {
const searchParams = new URLSearchParams(req.query)
const sharedSecret = searchParams.get('shared_secret')
if (sharedSecret !== IntegrationConfig.getPubSubWebhookSharedSecret({ ctx })) {
logger
.forBot()
.error(
'Received a webhook event with an invalid shared secret. Please ensure that the Pub/Sub subscription URL contains the same shared secret as the one configured in the integration settings.'
)
return false
}
return true
}
const _isJWTValid = async ({ req, client, ctx, logger }: bp.HandlerProps) => {
const authorizationHeader = req.headers.authorization
if (!authorizationHeader?.startsWith('Bearer ')) {
logger
.forBot()
.error(
'Received a webhook event without authentication. Please ensure that the Pub/Sub subscription is authenticated with a service account.'
)
return false
}
const bearerToken = authorizationHeader.slice(7)
const jwtVerifier = new JWTVerifier({ client, ctx, logger })
return await jwtVerifier.isJWTProperlySigned(bearerToken)
}
@@ -0,0 +1 @@
export * from './handler'
@@ -0,0 +1,195 @@
// @ts-ignore
import { AxiosError } from 'axios'
// @ts-ignore
import parseMessage from 'gmail-api-parse-message'
import { parse as parseHtml } from 'node-html-parser'
import { GoogleClient } from '../google-api'
import { decodeBase64URL } from '../utils/string-utils'
import * as bp from '.botpress'
export const handleIncomingEmail = async (props: bp.HandlerProps) => {
const { req, client, ctx, logger } = props
const bodyContent = JSON.parse(req.body || '{}')
const data = bodyContent.message?.data
if (!data) {
logger.warn('Handler received an invalid body (no data)')
return
}
const messageData = JSON.parse(decodeBase64URL(data))
const { historyId: historyIdNumber, emailAddress } = messageData
const historyId = `${historyIdNumber}`
if (!historyId) {
logger.warn('Handler received an invalid body (no historyId)')
return
}
const {
state: { payload },
} = await client.getState({
type: 'integration',
name: 'configuration',
id: ctx.integrationId,
})
const lastHistoryId = payload.lastHistoryId ?? _fakeHistoryId(historyId)
if (Number(historyId) <= Number(lastHistoryId)) {
logger.info(`HistoryId ${historyId} already processed (last: ${lastHistoryId}), skipping`)
return
}
await client.setState({
type: 'integration',
name: 'configuration',
id: ctx.integrationId,
payload: {
...payload,
lastHistoryId: historyId,
},
})
const googleClient = await GoogleClient.create({ client, ctx })
const history = await googleClient.getMyHistory(lastHistoryId)
const messageIds = history.history?.reduce((acc, h) => {
h.messagesAdded?.forEach((m) => {
if (m.message?.id) {
acc.push(m.message?.id)
}
})
return acc
}, [] as string[])
if (!messageIds?.length) {
logger.info('Handler received an empty message id')
return
}
for (const messageId of messageIds) {
await _processMessage(props, messageId, googleClient, emailAddress, logger)
}
}
const _processMessage = async (
{ client }: bp.HandlerProps,
messageId: string,
googleClient: GoogleClient,
emailAddress: string,
logger: bp.HandlerProps['logger']
) => {
let gmailMessage
try {
gmailMessage = await googleClient.messages.get(messageId)
} catch (error: unknown) {
if (error instanceof AxiosError && (error?.code === '404' || error?.response?.status === 404)) {
logger.info(`Message ${messageId} not found, skipping (likely deleted)`)
return
}
throw error
}
const message = parseMessage(gmailMessage)
const threadId = message.threadId
if (!threadId) {
logger.info('Handler received an empty chat id')
throw new Error('Handler received an empty chat id')
}
const replyTo = message.headers['reply-to']
const inReplyTo = message.headers['message-id']
const from = message.headers['from']
const { name: senderName, email: userEmail } = _extractNameAndEmailFromSender(replyTo ?? from)
if (userEmail === emailAddress) {
return
}
const { conversation } = await client.getOrCreateConversation({
channel: 'channel',
tags: {
id: `${threadId}`,
},
})
await client.updateConversation({
id: conversation.id,
tags: {
subject: message.headers['subject'],
email: userEmail,
references: message.headers['references'],
cc: message.headers['cc'],
},
})
if (!userEmail) {
throw new Error('Handler received an empty from id')
}
const { user } = await client.getOrCreateUser({
tags: {
id: `${userEmail}`,
},
name: senderName,
})
let content = message.textPlain ?? message.snippet
if (message.textHtml) {
try {
// Extract the body from the message:
const rootNode = parseHtml(message.textHtml)
const bodyNode = rootNode.querySelector('body')
const messageRoot = bodyNode ?? rootNode
// Remove previous quoted messages in the thread:
messageRoot.querySelectorAll('.gmail_quote')?.forEach((m) => m.remove())
// Extract the text content:
content = messageRoot.structuredText
} catch (thrown) {
logger.error('Error while parsing html content', thrown)
}
}
try {
await client.getOrCreateMessage({
tags: { id: messageId },
type: 'text',
userId: user.id,
conversationId: conversation.id,
payload: { text: content },
})
} catch (error: unknown) {
const err = error instanceof Error ? error : new Error(String(error))
if (err?.message?.includes('already exists for a different conversation')) {
logger.info(`Message ${messageId} already exists, skipping`)
return
}
throw error
}
await client.getOrSetState({
type: 'conversation',
name: 'thread',
id: conversation.id,
payload: {
inReplyTo,
},
})
}
const _fakeHistoryId = (historyId: string) => `${+historyId - 100}`
const _extractNameAndEmailFromSender = (sender: string) => {
const [nameAndWhitespaces, potentialEmail] = sender.trim().split('<')
const name = nameAndWhitespaces?.trimEnd() ?? ''
const email = potentialEmail ? (potentialEmail.split('>')[0] ?? '') : name
return { name: name || email, email }
}
@@ -0,0 +1,48 @@
import { generateRedirection, getInterstitialUrl } from '@botpress/common/src/oauth-wizard/interstitial'
import { GoogleClient } from '../google-api'
import * as bp from '.botpress'
export const handleOAuthCallback = async ({ req, client, ctx, logger }: bp.HandlerProps) => {
logger.forBot().info('Starting OAuth callback handling')
try {
const searchParams = new URLSearchParams(req.query)
const error = searchParams.get('error')
if (error) {
throw new Error(`${error} - ${searchParams.get('error_description') ?? ''}`)
}
const authorizationCode = searchParams.get('code')
if (!authorizationCode) {
throw new Error('Authorization code not present in OAuth callback')
}
logger.forBot().info('Creating Google client from authorization code')
const googleClient = await GoogleClient.createFromAuthorizationCode({
client,
ctx,
authorizationCode,
})
logger.forBot().info('Google client created successfully')
logger.forBot().info('Retrieving user email from Google profile')
const userEmail = await googleClient.getMyEmail()
if (!userEmail) {
throw new Error('Failed to extract email from Google profile')
}
logger.forBot().info(`User email retrieved: ${userEmail}`)
logger.forBot().info(`Configuring integration for user: ${userEmail}`)
await client.configureIntegration({
identifier: userEmail,
})
logger.forBot().info('Integration configured successfully')
return generateRedirection(getInterstitialUrl(true))
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
const errorMessage = 'OAuth error: ' + msg
logger.forBot().error(errorMessage)
return generateRedirection(getInterstitialUrl(false, errorMessage))
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "react",
"lib": ["es2022", "DOM"],
"paths": { "*": ["./*"] },
"outDir": "dist",
"experimentalDecorators": true,
"emitDecoratorMetadata": true
},
"include": [".botpress/**/*", "definitions/**/*", "src/**/*", "*.ts"]
}
+2
View File
@@ -0,0 +1,2 @@
import config from '../../vitest.config'
export default config