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
@@ -0,0 +1,173 @@
import { default as sdk, z } from '@botpress/sdk'
import * as entities from './entities'
export const actions = {
listEvents: {
title: 'List Events',
description: 'Retrieves events from the calendar.',
input: {
schema: z.object({
count: z
.number()
.min(1)
.max(2500)
.default(100)
.title('Number of events to retrieve')
.describe('The maximum number of events to return. Defaults to 100.'),
pageToken: z.string().title('Page token').optional().describe('Token specifying which result page to return.'),
timeMin: z
.string()
.title('On or after')
.optional()
.describe(
'Only return events occurring on or after this date. Must be in RFC3339 format. Defaults to now if not specified.'
),
}),
},
output: {
schema: z.object({
events: z
.array(entities.Event.schema.title('Event').describe('The calendar event data.'))
.title('Events')
.describe('The list of calendar events.'),
nextPageToken: z
.string()
.title('Next page token')
.optional()
.describe('Token used to access the next page of this result. Omitted if no further results are available.'),
}),
},
},
createEvent: {
title: 'Create Event',
description: 'Creates a new event in the calendar.',
input: {
schema: entities.Event.schema
.omit({ eventType: true, htmlLink: true, id: true, conferenceLink: true })
.extend({
attendees: z
.array(
z.object({
email: z.string().email().title('Email').describe('The email address of the attendee.'),
displayName: z
.string()
.title('Display Name')
.optional()
.describe('The name of the attendee. Optional.'),
optional: z
.boolean()
.title('Optional Attendee')
.optional()
.default(false)
.describe('Whether this is an optional attendee. Optional. The default is False.'),
})
)
.title('Attendees')
.optional()
.describe('List of attendees for the event. Email invitations will be sent automatically.'),
})
.title('New Event')
.describe('The definition of the new event.'),
},
output: {
schema: entities.Event.schema.title('Created Event').describe('The data of the new event.'),
},
},
updateEvent: {
title: 'Update Event',
description: 'Updates an existing event in the calendar. Omitted properties are left unchanged.',
input: {
schema: entities.Event.schema.omit({ eventType: true, htmlLink: true, conferenceLink: true }).extend({
id: z.string().title('Event ID').describe('The ID of the calendar event to update.'),
attendees: z
.array(
z.object({
email: z.string().email().title('Email').describe('The email address of the attendee.'),
displayName: z.string().title('Display Name').optional().describe('The name of the attendee. Optional.'),
optional: z
.boolean()
.title('Optional Attendee')
.optional()
.default(false)
.describe('Whether this is an optional attendee. Optional. The default is False.'),
})
)
.title('Attendees')
.optional()
.describe('List of attendees for the event.'),
}),
},
output: {
schema: entities.Event.schema.title('Updated Event').describe('The data of the event.'),
},
},
deleteEvent: {
title: 'Delete Event',
description: 'Deletes an event from the calendar.',
input: {
schema: z.object({
eventId: z.string().title('Event ID').describe('The ID of the calendar event to delete.'),
}),
},
output: {
schema: z.object({}),
},
},
checkAvailability: {
title: 'Check Availability',
description: 'Checks calendar availability and returns free time slots for the specified date range.',
input: {
schema: z.object({
timeMin: z
.string()
.title('Start Time')
.describe('Start of the time range to check (ISO 8601 format, e.g., "2025-11-05T09:00:00-04:00").'),
timeMax: z
.string()
.title('End Time')
.describe('End of the time range to check (ISO 8601 format, e.g., "2025-11-05T17:00:00-04:00").'),
slotDurationMinutes: z
.number()
.min(15)
.max(480)
.default(45)
.title('Slot Duration (minutes)')
.describe('Duration of each time slot in minutes. Defaults to 45 minutes.'),
timezone: z
.string()
.default('America/Toronto')
.title('Timezone')
.describe('Timezone for formatting output (e.g., "America/Toronto"). Defaults to America/Toronto.'),
}),
},
output: {
schema: z.object({
freeSlots: z
.array(
z.object({
start: z.string().title('Start Time').describe('ISO 8601 formatted start time of the free slot.'),
end: z.string().title('End Time').describe('ISO 8601 formatted end time of the free slot.'),
})
)
.title('Free Slots')
.describe('Array of available time slots in ISO format.'),
formattedFreeSlots: z
.array(z.string())
.title('Formatted Free Slots')
.describe('Array of human-readable formatted free slots (e.g., "9:00 AM 9:45 AM").'),
busySlots: z
.array(
z.object({
start: z.string().title('Start Time').describe('ISO 8601 formatted start time of the busy slot.'),
end: z.string().title('End Time').describe('ISO 8601 formatted end time of the busy slot.'),
})
)
.title('Busy Slots')
.describe('Array of busy time slots in ISO format.'),
}),
},
},
} as const satisfies sdk.IntegrationDefinitionProps['actions']
@@ -0,0 +1,45 @@
import { default as sdk, z } from '@botpress/sdk'
const _commonConfig = {
calendarId: z
.string()
.title('Calendar ID')
.min(1)
.describe('The ID of the Google Calendar to interact with. You can find it in your Google Calendar settings.'),
} as const
export const configuration = {
schema: z.object({
..._commonConfig,
}),
identifier: { linkTemplateScript: 'linkTemplate.vrl', required: true },
} as const satisfies sdk.IntegrationDefinitionProps['configuration']
export const identifier = undefined satisfies sdk.IntegrationDefinitionProps['identifier']
export const configurations = {
serviceAccountKey: {
title: 'Manual configuration',
description: 'Configure manually with a Service Account Key',
schema: z.object({
..._commonConfig,
privateKey: z
.string()
.title('Service account private key')
.min(1)
.describe('The private key from the Google service account. You can get it from the downloaded JSON file.'),
clientEmail: z
.string()
.title('Service account email')
.email()
.describe('The client email from the Google service account. You can get it from the downloaded JSON file.'),
impersonateEmail: z
.string()
.optional()
.title('Impersonate email')
.describe(
"The email of the user to impersonate. This is the email of the user you want to impersonate. It's mandatory for inviting people or creating meetings."
),
}),
},
} as const satisfies sdk.IntegrationDefinitionProps['configurations']
@@ -0,0 +1,117 @@
import { z } from '@botpress/sdk'
// documentation: https://developers.google.com/calendar/api/v3/reference/events#resource
export namespace Event {
const _fields = {
id: z.string().title('Event ID').describe('Opaque identifier of the event.'),
summary: z.string().title('Summary').describe('Title of the event.'),
description: z
.string()
.title('Description')
.optional()
.describe('Description of the event. Can contain HTML. Optional.'),
location: z
.string()
.title('Location')
.optional()
.describe('Geographic location of the event as free-form text. Optional. (e.g., "Meeting Room").'),
startDateTime: z
.string()
.title('Start Date/Time')
.describe('The start date and time in RFC3339 format (e.g., "2023-12-31T10:00:00.000Z").'),
endDateTime: z
.string()
.title('End Date/Time')
.describe('The end date and time in RFC3339 format (e.g., "2023-12-31T12:00:00.000Z").'),
colorId: z.string().title('Color ID').optional().describe('The color ID of the event. Optional.'),
eventType: z
.enum(['default', 'birthday', 'focusTime', 'fromGmail', 'outOfOffice', 'workingLocation'])
.title('Event Type')
.describe('Specific type of the event. This cannot be modified after the event is created.'),
guestsCanInviteOthers: z
.boolean()
.title('Guests Can Invite Others')
.optional()
.default(true)
.describe(
'Whether attendees other than the organizer can invite others to the event. Optional. The default is True.'
),
guestsCanSeeOtherGuests: z
.boolean()
.title('Guests Can See Other Guests')
.optional()
.default(true)
.describe(
"Whether attendees other than the organizer can see who the event's attendees are. Optional. The default is True."
),
htmlLink: z.string().title('HTML Link').describe('URL of the event page in the Google Calendar Web UI.'),
recurrence: z
.array(
z
.string()
.title('RFC5545 line')
.describe(
'RRULE, EXRULE, RDATE or EXDATE line, as specified in RFC5545. Note that DTSTART and DTEND lines are not allowed in this field'
)
)
.title('Recurrence')
.optional()
.describe(
'List of RRULE, EXRULE, RDATE and EXDATE lines for a recurring event, as specified in RFC5545. This field is omitted for single events or instances of recurring events.'
),
status: z
.enum(['confirmed', 'tentative', 'cancelled'])
.title('Event Status')
.optional()
.default('confirmed')
.describe('Status of the event. Optional. The default value is "confirmed".'),
visibility: z
.enum(['default', 'public', 'private', 'confidential'])
.title('Event Visibility')
.optional()
.default('default')
.describe('Visibility of the event. Optional. The default value is "default".'),
enableGoogleMeet: z
.boolean()
.title('Enable Google Meet')
.optional()
.default(false)
.describe('Whether to enable Google Meet for the event. Optional. The default value is false.'),
sendNotifications: z
.boolean()
.title('Send Notifications')
.optional()
.default(true)
.describe('Whether to send notifications to attendees. Optional. The default value is true.'),
attendees: z
.array(
z.object({
email: z.string().title('Email').describe('The email address of the attendee.'),
displayName: z.string().title('Display Name').optional().describe('The name of the attendee. Optional.'),
optional: z
.boolean()
.title('Optional Attendee')
.optional()
.default(false)
.describe('Whether this is an optional attendee. Optional. The default is False.'),
responseStatus: z
.enum(['needsAction', 'declined', 'tentative', 'accepted'])
.title('Response Status')
.optional()
.describe("The attendee's response status. Read-only when creating events."),
})
)
.title('Attendees')
.optional()
.describe('List of attendees for the event. Email invitations will be sent automatically.'),
conferenceLink: z
.string()
.title('Conference Link')
.optional()
.describe('The Google Meet link for the event. This is automatically generated when enableGoogleMeet is true.'),
} as const
export const schema = z.object(_fields)
export type inferredType = z.infer<typeof schema>
}
@@ -0,0 +1,12 @@
import * as sdk from '@botpress/sdk'
import { Event } from './event'
export { Event }
export const entities = {
event: {
title: 'Google Calendar Event',
description: 'An event in a Google Calendar',
schema: Event.schema,
},
} as const satisfies sdk.IntegrationDefinitionProps['entities']
@@ -0,0 +1,3 @@
import { default as sdk } from '@botpress/sdk'
export const events = {} as const satisfies sdk.IntegrationDefinitionProps['events']
@@ -0,0 +1,6 @@
export * from './actions'
export * from './configuration'
export * from './entities'
export * from './events'
export * from './secrets'
export * from './states'
@@ -0,0 +1,8 @@
import { posthogHelper } from '@botpress/common'
import { default as sdk } from '@botpress/sdk'
export const secrets = {
...posthogHelper.COMMON_SECRET_NAMES,
CLIENT_ID: { description: 'Google OAuth Client ID' },
CLIENT_SECRET: { description: 'Google OAuth Client Secret' },
} as const satisfies sdk.IntegrationDefinitionProps['secrets']
@@ -0,0 +1,13 @@
import sdk, { z } from '@botpress/sdk'
export const states = {
oAuthConfig: {
type: 'integration',
schema: z.object({
refreshToken: z
.string()
.title('Refresh token')
.describe('The refresh token to use to authenticate with Google APIs. It gets exchanged for a bearer token'),
}),
},
} as const satisfies sdk.IntegrationDefinitionProps['states']
@@ -0,0 +1,13 @@
import rootConfig from '../../eslint.config.mjs'
export default [
...rootConfig,
{
languageOptions: {
parserOptions: {
project: ['./tsconfig.json'],
tsconfigRootDir: import.meta.dirname,
},
},
},
]
+147
View File
@@ -0,0 +1,147 @@
The Google Calendar Integration allows you to seamlessly interact with Google Calendar within your Botpress bot. This integration provides various actions to manage calendar events, enhancing the functionality of your bot.
## Important note
Unfortunately, **automatic configuration is temporarily unavailable**.
We are currently in the process of getting our Google Calendar integration verified by Google. Once this verification is complete, you will be able to use the automatic configuration method to set up the Google Calendar integration with just a few clicks. Until then, you will need to create your own Google Cloud Platform (GCP) Service Account by following the steps outlined in the `Manual configuration using a service account` section below.
## Migrating from version `0.x` to `1.x`
If you are migrating from version `0.x` to `1.x`, please note the following changes:
> The integration now supports both OAuth and service account authentication methods. If you wish to continue using a service account key, you will need to select _Configure manually with a Service Account Key_ in the configuration dropdown menu and reconfigure the integration. See the _Manual configuration using a service account_ section down below for more information.
> When creating or updating calendar events, you can now optionally specify the recurrence and visibility settings for the event. These new fields are also now being returned when listing events.
> When creating or updating events, the ISO 8601 date-time format is now fully supported and it is no longer necessary to input dates as RFC 3339 strings.
## Migrating from version `1.x` to `2.x`
If you are migrating from version `1.x` to `2.x`, please note the following changes:
> The integration has been refactored to remove the generic CRUD interface actions and **de-duplicate redundant actions**. The action names have changed from the previous interface-based naming convention. You will need to update any workflows or code that references the old action names:
>
> - `eventCreate` → `createEvent`
> - `eventDelete` → `deleteEvent`
> - `eventList` → `listEvents`
> - `eventUpdate` → `updateEvent`
> - `eventRead` has been removed (use `listEvents`instead)
## Configuration
### Automatic configuration with OAuth (recommended)
To set up the Google Calendar integration using OAuth, click the authorization button and follow the on-screen instructions to connect your Botpress chatbot to Google Calendar.
When using this configuration mode, a Botpress-managed Google Calendar application will be used to connect to your Google 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 Google accounts** for this integration. You should set up a service account and use this account to authorize the connection.
Once the connection is established, you must specify the identifier of the calendar you want to interact with. This identifier can be found by navigating to the calendar in Google Calendar and opening the settings for that calendar. Once in the settings, you will find the _Calendar ID_ in the `Integrate calendar` section. This is the value you need to provide in the configuration.
1. Find your Google Calendar ID for the calendar you want to interact with.
2. Authorize the Google Calendar integration by clicking the authorization button.
3. Fill in the **Calendar ID** field and save the configuration.
### Manual configuration using a service account
#### 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 Google Calendar API
1. In the Google Cloud Console, navigate to the `APIs & Services` section.
2. Click on `Library` in the left sidebar.
3. Search for `Google Calendar API` and click on the result.
4. Click the `Enable` button to enable the Google Calendar API for your project.
#### 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. This should automatically fill the `Service account ID` field.
5. Click `Done` to proceed. There is no need to grant any roles or permissions at this stage.
#### Downloading the service account credentials file
1. In the Google Cloud Console, navigate to the `IAM & Admin` section.
2. Click on `Service Accounts` in the left sidebar.
3. Select the service account you created previously.
4. Click on the `Keys` tab.
5. Click the `Add Key` button and select `JSON`.
6. A JSON file containing the service account credentials will be downloaded to your computer. Save this file in a secure location, as it contains sensitive information. You will need this file to configure the Google Calendar integration in Botpress.
#### Locating your service account email and private key
1. Open the downloaded JSON file in a text editor.
2. Look for the `client_email` field. This is the email address of the service account you created. Copy the email address, excluding the quotation marks. You will need this email address to share your calendar with the service account and to configure the integration in Botpress.
3. Look for the `private_key` field. This is the private key associated with the service account. Copy the private key, excluding the quotation marks. You will need this private key to configure the integration in Botpress.
> This public key begins with `-----BEGIN PRIVATE KEY-----\n` and ends with `\n-----END PRIVATE KEY-----\n`. You must copy the entire key: everything that is between the quotation marks.
#### Sharing your calendar with the service account
1. Open Google Calendar in your web browser.
2. Find the calendar you want to access on Botpress.
3. Click on the three dots next to the calendar name and select `Settings and sharing`.
4. In the `Shared with` section, click on `Add people`.
5. Enter the service account email address (found in the downloaded JSON file) and select the appropriate permissions: `Make changes to events`.
> **Please note:** your organization may have restrictions on sharing calendars with external users. If you are unable to share the calendar with the service account email address, you may need to use a different account or ask your organization's administrator for help.
#### Locating your calendar ID
1. Open Google Calendar in your web browser.
2. Find the calendar you want to access on Botpress.
3. Click on the three dots next to the calendar name and select `Settings and sharing`.
4. In the `Integrate calendar` section, you will find the _Calendar ID_. You will need this ID to configure the integration in Botpress.
#### Configuring the Google Calendar integration in Botpress
1. Install this integration in your bot with the following configuration:
- **Calendar ID**: The ID of the Google Calendar to interact with.
- **Service account private key**: The private key from the Google service account. You can get it from the downloaded JSON file.
- **Service account email**: The client email from the Google service account. You can get it from the downloaded JSON file.
- **Impersonate email** (optional but required for certain features): The email address of the user to impersonate. See the "Using impersonateEmail" section below for details.
#### Using impersonateEmail
The `impersonateEmail` field is **required** for Google Meet creation and attendee invitations. This field must correspond to an email address of a person actually in your Google Workspace.
**When to use impersonateEmail:**
- **Required for**: Creating events with Google Meet links and inviting attendees to events
- **Not required for**: Creating simple events without meetings or attendees, reading calendar events. If you set it up without configuring the Domain-Wide Delegation, the integration won't configure properly
**How to configure:**
1. The `impersonateEmail` must be a valid email address of a user in your Google Workspace domain.
2. The service account must have Domain-Wide Delegation enabled.
3. The user whose email you're impersonating must exist in your Google Workspace and have appropriate calendar permissions.
## Usage
Once the Google Calendar Integration is configured, you can use it to manage calendar events within your Botpress bot. Here are some common use cases:
- Schedule appointments or events on Google Calendar.
- Retrieve upcoming events and display them to users.
- Update or delete events based on user requests.
The integration provides powerful capabilities to enhance your bot's scheduling and event management functionalities.
### Configuring event recurrence
When creating or updating a calendar event, you can specify the recurrence settings for the event. The recurrence settings are defined using the [RFC 5545](https://tools.ietf.org/html/rfc5545) standard, which allows you to define complex recurrence patterns for events. Here are some examples of recurrence rules:
- Repeat every day at the same time: `RRULE:FREQ=DAILY`
- Repeat every day for the next 5 days: `RRULE:FREQ=DAILY;COUNT=5`
- Repeat every week on Monday, Wednesday, and Friday: `RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR`
- Repeat every month on the 15th: `RRULE:FREQ=MONTHLY;BYMONTHDAY=15`
- Repeat every year on January 1st: `RRULE:FREQ=YEARLY;BYMONTH=1;BYMONTHDAY=1`
- Repeat on the first Monday of every month: `RRULE:FREQ=MONTHLY;BYDAY=1MO`
- Repeat every 2 weeks on Monday: `RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=MO`
- Repeat daily until a specific date: `RRULE:FREQ=DAILY;UNTIL=20261231T000000Z`
- Repeat every Monday, except for March 25, 2026: `RRULE:FREQ=WEEKLY;BYDAY=MO;EXDATE=20260325T000000Z`
You can use these recurrence rules to create events with complex repeating patterns. You may also use online RRULE generators to create custom recurrence rules for your events.
+27
View File
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<svg version="1.1" id="Livello_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 200 200" enable-background="new 0 0 200 200" xml:space="preserve">
<g>
<g transform="translate(3.75 3.75)">
<path fill="#FFFFFF" d="M148.882,43.618l-47.368-5.263l-57.895,5.263L38.355,96.25l5.263,52.632l52.632,6.579l52.632-6.579
l5.263-53.947L148.882,43.618z"/>
<path fill="#1A73E8" d="M65.211,125.276c-3.934-2.658-6.658-6.539-8.145-11.671l9.132-3.763c0.829,3.158,2.276,5.605,4.342,7.342
c2.053,1.737,4.553,2.592,7.474,2.592c2.987,0,5.553-0.908,7.697-2.724s3.224-4.132,3.224-6.934c0-2.868-1.132-5.211-3.395-7.026
s-5.105-2.724-8.5-2.724h-5.276v-9.039H76.5c2.921,0,5.382-0.789,7.382-2.368c2-1.579,3-3.737,3-6.487
c0-2.447-0.895-4.395-2.684-5.855s-4.053-2.197-6.803-2.197c-2.684,0-4.816,0.711-6.395,2.145s-2.724,3.197-3.447,5.276
l-9.039-3.763c1.197-3.395,3.395-6.395,6.618-8.987c3.224-2.592,7.342-3.895,12.342-3.895c3.697,0,7.026,0.711,9.974,2.145
c2.947,1.434,5.263,3.421,6.934,5.947c1.671,2.539,2.5,5.382,2.5,8.539c0,3.224-0.776,5.947-2.329,8.184
c-1.553,2.237-3.461,3.947-5.724,5.145v0.539c2.987,1.25,5.421,3.158,7.342,5.724c1.908,2.566,2.868,5.632,2.868,9.211
s-0.908,6.776-2.724,9.579c-1.816,2.803-4.329,5.013-7.513,6.618c-3.197,1.605-6.789,2.421-10.776,2.421
C73.408,129.263,69.145,127.934,65.211,125.276z"/>
<path fill="#1A73E8" d="M121.25,79.961l-9.974,7.25l-5.013-7.605l17.987-12.974h6.895v61.197h-9.895L121.25,79.961z"/>
<path fill="#EA4335" d="M148.882,196.25l47.368-47.368l-23.684-10.526l-23.684,10.526l-10.526,23.684L148.882,196.25z"/>
<path fill="#34A853" d="M33.092,172.566l10.526,23.684h105.263v-47.368H43.618L33.092,172.566z"/>
<path fill="#4285F4" d="M12.039-3.75C3.316-3.75-3.75,3.316-3.75,12.039v136.842l23.684,10.526l23.684-10.526V43.618h105.263
l10.526-23.684L148.882-3.75H12.039z"/>
<path fill="#188038" d="M-3.75,148.882v31.579c0,8.724,7.066,15.789,15.789,15.789h31.579v-47.368H-3.75z"/>
<path fill="#FBBC04" d="M148.882,43.618v105.263h47.368V43.618l-23.684-10.526L148.882,43.618z"/>
<path fill="#1967D2" d="M196.25,43.618V12.039c0-8.724-7.066-15.789-15.789-15.789h-31.579v47.368H196.25z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

@@ -0,0 +1,27 @@
import * as sdk from '@botpress/sdk'
import { actions, entities, configuration, configurations, identifier, events, secrets, states } from './definitions'
export const INTEGRATION_NAME = 'googlecalendar'
export const INTEGRATION_VERSION = '2.0.11'
export default new sdk.IntegrationDefinition({
name: INTEGRATION_NAME,
version: INTEGRATION_VERSION,
description: 'Sync with your calendar to manage events, appointments, and schedules directly within the chatbot.',
title: 'Google Calendar',
readme: 'hub.md',
icon: 'icon.svg',
configuration,
identifier,
configurations,
entities,
actions,
events,
secrets,
states,
attributes: {
category: 'Business Operations',
guideSlug: 'googlecalendar',
repo: 'botpress',
},
})
@@ -0,0 +1,17 @@
webhookId = to_string!(.webhookId)
webhookUrl = to_string!(.webhookUrl)
env = to_string!(.env)
clientId = "126949014117-2gdb0rumoss7popm6j6io327qm2h5jdu.apps.googleusercontent.com"
if env == "production" {
clientId = "337740323093-ikgg567lpcao95abfis0ndo7mvdhebvp.apps.googleusercontent.com"
}
scopes = [
"https://www.googleapis.com/auth/calendar.events",
"https://www.googleapis.com/auth/calendar.readonly",
]
scopesStr = encode_percent(join!(scopes, " "))
"https://accounts.google.com/o/oauth2/v2/auth?scope={{ scopesStr }}&access_type=offline&response_type=code&prompt=consent&state={{ webhookId }}&redirect_uri={{ webhookUrl }}/oauth&client_id={{ clientId }}"
+27
View File
@@ -0,0 +1,27 @@
{
"name": "@botpresshub/googlecalendar",
"private": true,
"scripts": {
"build": "bp add -y && bp build",
"check:type": "tsc --noEmit",
"check:bplint": "bp lint",
"test": "vitest --run"
},
"dependencies": {
"@botpress/client": "workspace:*",
"@botpress/common": "workspace:*",
"@botpress/sdk": "workspace:*",
"@botpress/sdk-addons": "workspace:*",
"googleapis": "^144.0.0"
},
"devDependencies": {
"@botpress/cli": "workspace:*",
"@botpress/sdk": "workspace:*",
"@botpresshub/creatable": "workspace:*",
"@botpresshub/deletable": "workspace:*",
"@botpresshub/listable": "workspace:*",
"@botpresshub/readable": "workspace:*",
"@botpresshub/updatable": "workspace:*"
},
"bpDependencies": {}
}
@@ -0,0 +1,26 @@
import { createActionWrapper } from '@botpress/common'
import { GoogleClient, wrapAsyncFnWithTryCatch } from '../google-api'
import * as bp from '.botpress'
export const wrapAction: typeof _wrapAction = (meta, actionImpl) =>
_wrapAction(meta, (props) =>
// NOTE: the GoogleClient class already has error handling with error
// redaction, so this try-catch wrapper will only ever be called
// if there's an error in the action implementation itself
wrapAsyncFnWithTryCatch(() => {
props.logger
.forBot()
.debug(`Running action "${meta.actionName}" [bot id: ${props.ctx.botId}]`, { input: props.input })
return actionImpl(props as Parameters<typeof actionImpl>[0], props.input)
}, `Action Error: ${meta.errorMessageWhenFailed}`)()
)
const _wrapAction = createActionWrapper<bp.IntegrationProps>()({
toolFactories: {
googleClient: GoogleClient.create,
},
extraMetadata: {} as {
errorMessageWhenFailed: string
},
})
@@ -0,0 +1,90 @@
import { wrapAction } from '../action-wrapper'
type TimeSlot = {
start: Date
end: Date
}
function _addMinutes(date: Date, minutes: number): Date {
return new Date(date.getTime() + minutes * 60_000)
}
function _doTimeRangesOverlap(start1: Date, end1: Date, start2: Date, end2: Date): boolean {
return start1 < end2 && end1 > start2
}
function _formatTime(date: Date, timezone: string): string {
return date.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true,
timeZone: timezone,
})
}
function generateTimeSlots(startTime: Date, endTime: Date, slotDurationMinutes: number): TimeSlot[] {
const slots: TimeSlot[] = []
let currentSlotStart = new Date(startTime)
while (currentSlotStart < endTime) {
const currentSlotEnd = _addMinutes(currentSlotStart, slotDurationMinutes)
if (currentSlotEnd > endTime) {
break
}
slots.push({
start: new Date(currentSlotStart),
end: currentSlotEnd,
})
currentSlotStart = _addMinutes(currentSlotStart, slotDurationMinutes)
}
return slots
}
function filterAvailableSlots(allSlots: TimeSlot[], busyTimes: TimeSlot[]): TimeSlot[] {
return allSlots.filter((slot) => {
const hasConflict = busyTimes.some((busySlot) =>
_doTimeRangesOverlap(slot.start, slot.end, busySlot.start, busySlot.end)
)
return !hasConflict
})
}
export const checkAvailability = wrapAction(
{ actionName: 'checkAvailability', errorMessageWhenFailed: 'Failed to check calendar availability' },
async ({ googleClient }, input) => {
const { busySlots } = await googleClient.getBusySlots({
timeMin: input.timeMin,
timeMax: input.timeMax,
})
const searchStartTime = new Date(input.timeMin)
const searchEndTime = new Date(input.timeMax)
const busyTimeSlots: TimeSlot[] = busySlots.map((slot) => ({
start: new Date(slot.start),
end: new Date(slot.end),
}))
const allPossibleSlots = generateTimeSlots(searchStartTime, searchEndTime, input.slotDurationMinutes || 45)
const availableSlots = filterAvailableSlots(allPossibleSlots, busyTimeSlots)
const freeSlotsISO = availableSlots.map((slot) => ({
start: slot.start.toISOString(),
end: slot.end.toISOString(),
}))
const freeSlotsHumanReadable = availableSlots.map(
(slot) =>
`${_formatTime(slot.start, input.timezone || 'America/Toronto')} ${_formatTime(slot.end, input.timezone || 'America/Toronto')}`
)
return {
freeSlots: freeSlotsISO,
formattedFreeSlots: freeSlotsHumanReadable,
busySlots,
}
}
)
@@ -0,0 +1,6 @@
import { wrapAction } from '../action-wrapper'
export const createEvent = wrapAction(
{ actionName: 'createEvent', errorMessageWhenFailed: 'Failed to create calendar event' },
async ({ googleClient }, event) => await googleClient.createEvent({ event })
)
@@ -0,0 +1,6 @@
import { wrapAction } from '../action-wrapper'
export const deleteEvent = wrapAction(
{ actionName: 'deleteEvent', errorMessageWhenFailed: 'Failed to delete calendar event' },
async ({ googleClient }, { eventId }) => await googleClient.deleteEvent({ eventId })
)
@@ -0,0 +1,11 @@
import { wrapAction } from '../action-wrapper'
export const listEvents = wrapAction(
{ actionName: 'listEvents', errorMessageWhenFailed: 'Failed to list calendar events' },
async ({ googleClient }, { count, pageToken, timeMin }) =>
await googleClient.listEvents({
fetchAmount: count ?? 100,
minDate: timeMin ?? new Date().toISOString(),
pageToken,
})
)
@@ -0,0 +1,6 @@
import { wrapAction } from '../action-wrapper'
export const updateEvent = wrapAction(
{ actionName: 'updateEvent', errorMessageWhenFailed: 'Failed to update calendar event' },
async ({ googleClient }, event) => await googleClient.updateEvent({ event })
)
@@ -0,0 +1,14 @@
import { checkAvailability } from './implementations/check-availability'
import { createEvent } from './implementations/create-event'
import { deleteEvent } from './implementations/delete-event'
import { listEvents } from './implementations/list-events'
import { updateEvent } from './implementations/update-event'
import * as bp from '.botpress'
export const actions = {
createEvent,
deleteEvent,
listEvents,
updateEvent,
checkAvailability,
} as const satisfies bp.IntegrationProps['actions']
@@ -0,0 +1,65 @@
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: 'google_calendar_api_error',
properties: {
from: 'google_calendar_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 as any).POSTHOG_KEY as string,
}
)
.catch(() => {
// Silently fail if PostHog is unavailable
})
console.warn(customMessage, error)
return new sdk.RuntimeError(redactedMessage)
})
export const handleErrorsDecorator = createErrorHandlingDecorator(wrapAsyncFnWithTryCatch)
const _extractGoogleApiError = (error: Error) =>
_isGaxiosError(error)
? error['errors']
.map((err: { message: string }) => err.message)
.join(', ')
.replaceAll(/Invalid requests\[0\].[a-zA-Z]+:/g, '')
: null
// For some reason, the Google API typing for GaxiosError does not correspond
// to the actual error object returned by the API. It is missing the `errors`
// field which contains the actual error messages. This type is a workaround
// to properly type the error object.
type AggregateGAxiosError = GoogleApisCommon.GaxiosError & { errors: Error[] }
const _isGaxiosError = (error: Error): error is AggregateGAxiosError =>
'errors' in error && Array.isArray(error['errors'])
@@ -0,0 +1,132 @@
import { google } from 'googleapis'
import { handleErrorsDecorator as handleErrors } from './error-handling'
import { RequestMapping, ResponseMapping } from './mapping'
import { exchangeAuthCodeAndSaveRefreshToken, getAuthenticatedOAuth2Client } from './oauth-client'
import { CreateEventRequest, GoogleCalendarClient, GoogleOAuth2Client, Event, UpdateEventRequest } from './types'
import * as bp from '.botpress'
export class GoogleClient {
private readonly _calendarClient: GoogleCalendarClient
private readonly _calendarId: string
private constructor({ calendarId, oauthClient }: { calendarId: string; oauthClient: GoogleOAuth2Client }) {
this._calendarId = calendarId
this._calendarClient = google.calendar({ version: 'v3', auth: oauthClient })
}
public static async create({ ctx, client }: { ctx: bp.Context; client: bp.Client }) {
const oauth2Client = await getAuthenticatedOAuth2Client({ ctx, client })
return new GoogleClient({
oauthClient: oauth2Client,
calendarId: ctx.configuration.calendarId,
})
}
@handleErrors('Failed to authenticate Google Calendar with authorization code')
public static async authenticateWithAuthorizationCode({
ctx,
client,
authorizationCode,
}: {
ctx: bp.Context
client: bp.Client
authorizationCode: string
}) {
await exchangeAuthCodeAndSaveRefreshToken({ ctx, client, authorizationCode })
}
@handleErrors('Failed to get calendar summary')
public async getCalendarSummary() {
const { data } = await this._calendarClient.calendars.get({ calendarId: this._calendarId, fields: 'summary' })
return data.summary ?? 'Untitled'
}
@handleErrors('Failed to create calendar event')
public async createEvent({ event }: { event: CreateEventRequest }): Promise<Event> {
const { data } = await this._calendarClient.events.insert({
calendarId: this._calendarId,
requestBody: RequestMapping.mapCreateEvent(event),
conferenceDataVersion: event.enableGoogleMeet ? 1 : undefined,
sendUpdates: event.sendNotifications !== false && event.attendees && event.attendees.length > 0 ? 'all' : 'none',
})
return ResponseMapping.mapEvent(data)
}
@handleErrors('Failed to update calendar event')
public async updateEvent({ event }: { event: UpdateEventRequest }): Promise<Event> {
const { data } = await this._calendarClient.events.update({
calendarId: this._calendarId,
eventId: event.id,
requestBody: RequestMapping.mapUpdateEvent(event),
conferenceDataVersion: event.enableGoogleMeet ? 1 : undefined,
sendUpdates: event.sendNotifications !== false && event.attendees && event.attendees.length > 0 ? 'all' : 'none',
})
return ResponseMapping.mapEvent(data)
}
@handleErrors('Failed to delete calendar event')
public async deleteEvent({ eventId }: { eventId: Event['id'] }): Promise<void> {
await this._calendarClient.events.delete({
calendarId: this._calendarId,
eventId,
})
}
@handleErrors('Failed to get calendar event')
public async getEvent({ eventId }: { eventId: Event['id'] }): Promise<Event> {
const { data } = await this._calendarClient.events.get({
calendarId: this._calendarId,
eventId,
})
return ResponseMapping.mapEvent(data)
}
@handleErrors('Failed to list calendar events')
public async listEvents({
fetchAmount,
minDate,
pageToken,
}: {
fetchAmount: number
minDate: string
pageToken?: string
}) {
const { data } = await this._calendarClient.events.list({
calendarId: this._calendarId,
maxResults: fetchAmount,
timeMin: minDate,
pageToken,
})
return {
events: ResponseMapping.mapEvents(data.items),
nextPageToken: ResponseMapping.mapNextToken(data.nextPageToken),
}
}
@handleErrors('Failed to check calendar availability')
public async getBusySlots({ timeMin, timeMax }: { timeMin: string; timeMax: string }) {
const { data } = await this._calendarClient.freebusy.query({
requestBody: {
timeMin,
timeMax,
items: [{ id: this._calendarId }],
},
})
const calendarBusy = data.calendars?.[this._calendarId]?.busy || []
return {
busySlots: calendarBusy.map((slot) => ({
start: slot.start || '',
end: slot.end || '',
})),
}
}
}
@@ -0,0 +1,2 @@
export * from './google-client'
export * from './error-handling'
@@ -0,0 +1,97 @@
import { describe, it, expect } from 'vitest'
import { IsoToRFC3339 } from './iso-to-rfc3339'
import sdk from '@botpress/sdk'
describe('IsoToRFC3339.convertDate', () => {
it.each([
// RFC3339 / ISO8601
['full RFC3339', '2024-01-01T00:00:00Z', '2024-01-01T00:00:00Z'],
['RFC3339 with ms', '2024-01-01T00:00:00.123Z', '2024-01-01T00:00:00.123Z'],
['positive UTC offset', '2024-01-01T00:00:00+00:00', '2024-01-01T00:00:00Z'],
['negative UTC offset', '2024-01-01T00:00:00-00:00', '2024-01-01T00:00:00Z'],
['positive timezone', '2024-01-01T05:00:00+05:00', '2024-01-01T00:00:00Z'],
['negative timezone', '2024-01-01T00:00:00-05:00', '2024-01-01T05:00:00Z'],
['compact timezone', '2024-01-01T00:00:00+0000', '2024-01-01T00:00:00Z'],
// Partial dates (interpreted as local time and converted to UTC)
['date only', '2024-01-01', '2024-01-01T00:00:00Z'],
//['date and hours', '2024-01-01T12', '2024-01-01T12:00:00Z'],
['date and time', '2024-01-01T12:30', '2024-01-01T12:30:00Z'],
['date and time with seconds', '2024-01-01T12:30:45', '2024-01-01T12:30:45Z'],
['date and time with ms', '2024-01-01T12:30:45.123', '2024-01-01T12:30:45.123Z'],
// Common formats
['date with spaces', '2024-01-01 00:00:00', '2024-01-01T00:00:00Z'],
['date with spaces and ms', '2024-01-01 00:00:00.123', '2024-01-01T00:00:00.123Z'],
['date with spaces and timezone', '2024-01-01 00:00:00 Z', '2024-01-01T00:00:00Z'],
])('converts %s correctly', (_, input, expected) => {
expect(IsoToRFC3339.convertDate(input)).toBe(expected)
})
it('handles Date object input', () => {
const date = new Date('2024-01-01T00:00:00Z')
expect(IsoToRFC3339.convertDate(date)).toBe('2024-01-01T00:00:00Z')
})
it('preserves milliseconds from Date object', () => {
const date = new Date('2024-01-01T00:00:00.123Z')
expect(IsoToRFC3339.convertDate(date)).toBe('2024-01-01T00:00:00.123Z')
})
it('omits milliseconds when zero', () => {
const date = new Date('2024-01-01T00:00:00.000Z')
expect(IsoToRFC3339.convertDate(date)).toBe('2024-01-01T00:00:00Z')
})
describe('time components', () => {
it('handles hours correctly', () => {
const date = new Date('2024-01-01T00:00:00Z')
date.setUTCHours(23)
expect(IsoToRFC3339.convertDate(date)).toBe('2024-01-01T23:00:00Z')
})
it('handles minutes correctly', () => {
const date = new Date('2024-01-01T00:00:00Z')
date.setUTCMinutes(59)
expect(IsoToRFC3339.convertDate(date)).toBe('2024-01-01T00:59:00Z')
})
it('handles seconds correctly', () => {
const date = new Date('2024-01-01T00:00:00Z')
date.setUTCSeconds(59)
expect(IsoToRFC3339.convertDate(date)).toBe('2024-01-01T00:00:59Z')
})
it('handles milliseconds correctly', () => {
const date = new Date('2024-01-01T00:00:00Z')
date.setUTCMilliseconds(999)
expect(IsoToRFC3339.convertDate(date)).toBe('2024-01-01T00:00:00.999Z')
})
})
describe('date components', () => {
it.each([
[2024, 1, 1, '2024-01-01T00:00:00Z'],
[2024, 12, 31, '2024-12-31T00:00:00Z'],
[2024, 2, 29, '2024-02-29T00:00:00Z'], // leap year
[1970, 1, 1, '1970-01-01T00:00:00Z'], // unix epoch
[9999, 12, 31, '9999-12-31T00:00:00Z'], // far future
])('formats date %i-%i-%i correctly', (year, month, day, expected) => {
const date = new Date(Date.UTC(year, month - 1, day))
expect(IsoToRFC3339.convertDate(date)).toBe(expected)
})
})
describe('error handling', () => {
it.each([
['empty string', ''],
['invalid format', 'not-a-date'],
['invalid month', '2024-13-01'],
['invalid day', '2024-01-32'],
['garbage text', 'hello world'],
])('throws RuntimeError for %s', (_, invalidInput) => {
expect(() => IsoToRFC3339.convertDate(invalidInput)).toThrow(sdk.RuntimeError)
expect(() => IsoToRFC3339.convertDate(invalidInput)).toThrow(`Invalid date: ${invalidInput}`)
})
})
})
@@ -0,0 +1,31 @@
import sdk from '@botpress/sdk'
export namespace IsoToRFC3339 {
export const convertDate = (input: string | Date): string => {
const date = input instanceof Date ? input : new Date(_parseDateString(input))
if (!_isValidDate(date)) {
throw new sdk.RuntimeError(`Invalid date: ${input}`)
}
return date.toISOString().replace(/\.000Z$/, 'Z')
}
const _parseDateString = (dateString: string): string =>
_ensureTimeZoneIsPresent(_insertColonInTimeZone(_removeWhitespace(_replaceSpaceWithT(dateString))))
const _replaceSpaceWithT = (input: string): string => input.trim().replace(' ', 'T')
const _removeWhitespace = (input: string): string => input.replace(/\s+/g, '')
const _insertColonInTimeZone = (input: string): string => input.replace(/([+-]\d{2})(\d{2})$/, '$1:$2')
const _ensureTimeZoneIsPresent = (input: string): string => {
if (!/Z$|[+-]\d{2}:\d{2}$/.test(input)) {
return input + 'Z' // Assume UTC
}
return input
}
const _isValidDate = (date: Date): boolean => date instanceof Date && !isNaN(date.getTime())
}
@@ -0,0 +1,2 @@
export * from './response-mapping'
export * from './request-mapping'
@@ -0,0 +1,42 @@
import type { calendar_v3 } from 'googleapis'
import { randomUUID } from 'node:crypto'
import { CreateEventRequest, UpdateEventRequest } from '../types'
import { IsoToRFC3339 } from './datetime-utils/iso-to-rfc3339'
export namespace RequestMapping {
export const mapCreateEvent = (event: CreateEventRequest): calendar_v3.Schema$Event => {
const mappedEvent: calendar_v3.Schema$Event = {
...event,
start: _mapDateTime(event.startDateTime),
end: _mapDateTime(event.endDateTime),
}
if (event.enableGoogleMeet) {
mappedEvent.conferenceData = {
createRequest: {
requestId: randomUUID(),
conferenceSolutionKey: {
type: 'hangoutsMeet',
},
},
}
}
if (event.attendees && event.attendees.length > 0) {
mappedEvent.attendees = event.attendees.map((attendee) => ({
email: attendee.email,
displayName: attendee.displayName,
optional: attendee.optional ?? false,
}))
}
return mappedEvent
}
export const mapUpdateEvent: (event: UpdateEventRequest) => calendar_v3.Schema$Event = mapCreateEvent
const _mapDateTime = (dateTime: string): calendar_v3.Schema$EventDateTime => ({
// The replaceAll is used to remove the extra quotes from the input created by the studio
dateTime: IsoToRFC3339.convertDate(dateTime.replaceAll('"', '')),
})
}
@@ -0,0 +1,97 @@
import type { calendar_v3 } from 'googleapis'
import { Event } from '../types'
export namespace ResponseMapping {
export const mapEvent = (event: calendar_v3.Schema$Event): Event => {
const conferenceLink =
event.hangoutLink ?? event.conferenceData?.entryPoints?.find((entry) => entry.entryPointType === 'video')?.uri
const attendees = event.attendees?.map((attendee) => ({
email: attendee.email ?? '',
displayName: attendee.displayName ?? undefined,
optional: attendee.optional ?? false,
responseStatus: _mapEnum({
value: attendee.responseStatus,
mapping: {
needsAction: 'needsAction',
declined: 'declined',
tentative: 'tentative',
accepted: 'accepted',
},
defaultValue: 'needsAction',
}),
}))
return {
id: event.id ?? '',
description: event.description ?? '',
summary: event.summary ?? '',
location: event.location ?? '',
startDateTime: event.start?.dateTime ?? '',
endDateTime: event.end?.dateTime ?? '',
eventType: _mapEventType(event.eventType),
guestsCanInviteOthers: event.guestsCanInviteOthers ?? true,
guestsCanSeeOtherGuests: event.guestsCanSeeOtherGuests ?? true,
htmlLink: event.htmlLink ?? '',
recurrence: event.recurrence ?? [],
status: _mapEventStatus(event.status),
colorId: event.colorId ?? '',
visibility: _mapEventVisibility(event.visibility),
enableGoogleMeet: !!event.conferenceData,
sendNotifications: true,
conferenceLink: conferenceLink ?? undefined,
attendees: attendees && attendees.length > 0 ? attendees : undefined,
}
}
export const mapEvents = (events?: calendar_v3.Schema$Event[]): Event[] => events?.map(mapEvent) ?? []
const _mapEventType = (eventType: calendar_v3.Schema$Event['eventType']): Event['eventType'] =>
_mapEnum({
value: eventType,
mapping: {
default: 'default',
birthday: 'birthday',
focusTime: 'focusTime',
fromGmail: 'fromGmail',
outOfOffice: 'outOfOffice',
workingLocation: 'workingLocation',
},
defaultValue: 'default',
})
const _mapEventStatus = (status: calendar_v3.Schema$Event['status']): Event['status'] =>
_mapEnum({
value: status,
mapping: {
confirmed: 'confirmed',
tentative: 'tentative',
cancelled: 'cancelled',
},
defaultValue: 'confirmed',
})
const _mapEventVisibility = (visibility: calendar_v3.Schema$Event['visibility']): Event['visibility'] =>
_mapEnum({
value: visibility,
mapping: {
default: 'default',
confidential: 'confidential',
private: 'private',
public: 'public',
},
defaultValue: 'default',
})
export const mapNextToken = (nextPageToken?: string | null) => nextPageToken ?? undefined
}
const _mapEnum = <TInput extends string | null | undefined, TOutput extends string>({
value,
mapping,
defaultValue,
}: {
value: TInput
mapping: Record<string, TOutput>
defaultValue: TOutput
}): TOutput => mapping[value ?? ''] ?? defaultValue
@@ -0,0 +1,66 @@
import * as sdk from '@botpress/sdk'
import { google } from 'googleapis'
import * as bp from '.botpress'
type GoogleOAuth2Client = InstanceType<(typeof google.auth)['OAuth2']>
const OAUTH_SCOPES = ['https://www.googleapis.com/auth/calendar.events', 'https://www.googleapis.com/auth/calendar']
const GLOBAL_OAUTH_ENDPOINT = `${process.env.BP_WEBHOOK_URL}/oauth`
export const exchangeAuthCodeAndSaveRefreshToken = async ({
ctx,
client,
authorizationCode,
}: {
ctx: bp.Context
client: bp.Client
authorizationCode: string
}) => {
const oauth2Client = _getPlainOAuth2Client()
const { tokens } = await oauth2Client.getToken({
code: authorizationCode,
})
if (!tokens.refresh_token) {
throw new sdk.RuntimeError('Unable to obtain refresh token. Please try the OAuth flow again.')
}
await client.setState({
id: ctx.integrationId,
type: 'integration',
name: 'oAuthConfig',
payload: { refreshToken: tokens.refresh_token },
})
}
export const getAuthenticatedOAuth2Client = async ({
ctx,
client,
}: {
ctx: bp.Context
client: bp.Client
}): Promise<GoogleOAuth2Client> => {
if (ctx.configurationType === 'serviceAccountKey') {
return new google.auth.JWT({
email: ctx.configuration.clientEmail,
key: ctx.configuration.privateKey.split(String.raw`\n`).join('\n'),
scopes: OAUTH_SCOPES,
subject: ctx.configuration?.impersonateEmail || undefined, // Ensure that the impersonate email is undefined if empty string
})
}
const oauth2Client = _getPlainOAuth2Client()
const { state } = await client.getState({
id: ctx.integrationId,
type: 'integration',
name: 'oAuthConfig',
})
oauth2Client.setCredentials({ refresh_token: state.payload.refreshToken })
return oauth2Client
}
const _getPlainOAuth2Client = (): GoogleOAuth2Client =>
new google.auth.OAuth2(bp.secrets.CLIENT_ID, bp.secrets.CLIENT_SECRET, GLOBAL_OAUTH_ENDPOINT)
@@ -0,0 +1,34 @@
import { Event as EventEntity } from 'definitions'
import { google } from 'googleapis'
export type GoogleCalendarClient = ReturnType<typeof google.calendar>
export type GoogleOAuth2Client = InstanceType<(typeof google.auth)['OAuth2']>
// Entities:
export type Event = EventEntity.inferredType
type BareMinimumEvent = PartialExcept<Event, 'startDateTime' | 'endDateTime'>
// Action requests:
export type CreateEventRequest = Omit<BareMinimumEvent, 'id' | 'eventType' | 'htmlLink' | 'attendees'> & {
attendees?: Array<{
email: string
displayName?: string
optional?: boolean
responseStatus?: 'tentative' | 'needsAction' | 'declined' | 'accepted'
}>
}
export type UpdateEventRequest = Omit<BareMinimumEvent, 'eventType' | 'htmlLink' | 'attendees'> & {
attendees?: Array<{
email: string
displayName?: string
optional?: boolean
responseStatus?: 'tentative' | 'needsAction' | 'declined' | 'accepted'
}>
}
// Type utilities:
/** Like Pick<T,K>, but each property is required */
type PickRequired<T, K extends keyof T> = { [P in K]-?: T[P] }
/** Makes all properties of T optional, except K, which are all required */
type PartialExcept<T, K extends keyof T> = Partial<Omit<T, K>> & PickRequired<T, K>
+23
View File
@@ -0,0 +1,23 @@
import { posthogHelper } from '@botpress/common'
import { INTEGRATION_NAME, INTEGRATION_VERSION } from 'integration.definition'
import { actions } from './actions'
import { register, unregister } from './setup'
import { handler } from './webhook-events'
import * as bp from '.botpress'
const integrationConfig: bp.IntegrationProps = {
register,
unregister,
actions,
channels: {},
handler,
}
export default posthogHelper.wrapIntegration(
{
integrationName: INTEGRATION_NAME,
key: bp.secrets.POSTHOG_KEY,
integrationVersion: INTEGRATION_VERSION,
},
integrationConfig
)
+13
View File
@@ -0,0 +1,13 @@
import { GoogleClient } from './google-api/google-client'
import * as bp from '.botpress'
export const register: bp.IntegrationProps['register'] = async ({ logger, ctx, client }) => {
logger.forBot().info('Registering Google Calendar integration')
const googleClient = await GoogleClient.create({ ctx, client })
const summary = await googleClient.getCalendarSummary()
logger.forBot().info(`Successfully connected to Google Calendar: ${summary}`)
}
export const unregister: bp.IntegrationProps['unregister'] = async () => {}
@@ -0,0 +1,11 @@
import { oauthCallbackHandler } from './handlers/oauth-callback'
import * as bp from '.botpress'
export const handler: bp.IntegrationProps['handler'] = async ({ client, ctx, logger, req }) => {
if (req.path === '/oauth') {
logger.forBot().info('Handling Google Calendar OAuth callback')
await oauthCallbackHandler({ client, ctx, req, logger })
} else {
logger.forBot().debug('Received unsupported webhook event', { path: req.path, query: req.query })
}
}
@@ -0,0 +1,34 @@
import { generateRedirection, getInterstitialUrl } from '@botpress/common/src/oauth-wizard/interstitial'
import { GoogleClient } from 'src/google-api'
import * as bp from '.botpress'
export const oauthCallbackHandler = async ({ client, ctx, req, logger }: bp.HandlerProps) => {
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')
}
await GoogleClient.authenticateWithAuthorizationCode({
client,
ctx,
authorizationCode,
})
await client.configureIntegration({ identifier: ctx.webhookId })
logger.forBot().info('Successfully authenticated with Google Calendar')
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))
}
}
@@ -0,0 +1 @@
export * from './handler-dispatcher'
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"paths": { "*": ["./*"] },
"outDir": "dist",
"experimentalDecorators": true,
"emitDecoratorMetadata": true
},
"include": [".botpress/**/*", "definitions/**/*", "src/**/*", "*.ts"]
}
@@ -0,0 +1,2 @@
import config from '../../vitest.config'
export default config