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,20 @@
import { z } from '@botpress/sdk'
export const scheduleEventInputSchema = z.object({
conversationId: z
.string()
.describe('The ID of the conversation')
.placeholder('{{ event.conversationId }}')
.title('Conversation ID'),
eventTypeUrl: z.string().describe('The URL of the event type').title('Event Type URL'),
})
export const scheduleEventOutputSchema = z.object({
bookingUrl: z
.string()
.url()
.describe('A link that brings the user to the event scheduling form')
.title('Scheduling Link URL'),
owner: z.string().url().describe('A link to the resource that owns this scheduling link').title('Event Owner'),
ownerType: z.string().describe('The type of the resource that owns this scheduling link').title('Resource Type'),
})
@@ -0,0 +1,21 @@
import { z } from '@botpress/sdk'
export const inviteeEventOutputSchema = z.object({
scheduledEventUri: z.string().url().describe('The URI of the scheduled event').title('Scheduled Event URI'),
eventName: z.string().describe('The name of the scheduled event').title('Scheduled Event Name'),
startTime: z.string().datetime().describe('The start time of the scheduled event').title('Start Time'),
endTime: z.string().datetime().describe('The end time of the scheduled event').title('End Time'),
locationType: z
.string()
.describe('The type of location for the scheduled event')
.title("Location Type (e.g. 'zoom', 'google_conference', 'microsoft_teams_conference')"),
organizerName: z.string().describe('The name of the event organizer').title('Organizer Name'),
organizerEmail: z.string().email().describe('The email of the event organizer').title('Organizer Email'),
inviteeName: z.string().describe('The name of the invitee').title('Invitee Name'),
inviteeEmail: z.string().email().describe('The email of the invitee').title('Invitee Email'),
conversationId: z
.string()
.nullable()
.describe('The conversation ID associated with the event, if available')
.title('Conversation ID'),
})
+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,
},
},
},
]
+19
View File
@@ -0,0 +1,19 @@
# Calendly Integration
## Overview
`@botpresshub/calendly` is an integration that allows a Botpress chatbot to schedule events via Calendly. It also allows the chatbot respond when an event is scheduled, cancelled, as well as, if the invitee did not show up to an event.
## Configuration
### Acquiring an access token (https://calendly.com/integrations/api_webhooks)
1. Login to the Calendly dashboard (https://calendly.com/login/)
2. In the navigation bar on the left, click on "Integrations & apps"
3. In the search bar type "Webhooks" and click on "API and webhooks"
4. Under "Your personal access tokens" click "Generate New Token"
5. Give the token a name (e.g. 'Botpress Chatbot') and click "Create token"
6. Validate your account by providing the verification code (typically sent to the account's email address)
- If you don't see it in your inbox be sure to also check your Junk/Spam folder
7. Click the "Copy token" button and save it to a secure location as it will only be shown once.
8. Finally, paste the Personal Access Token into the Botpress config
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 525.8 535.73"><defs><style>.cls-1{fill:none;}.cls-2{fill:#006bff;}.cls-3{fill:#0ae9ef;}</style></defs><g id="Layer_2" data-name="Layer 2"><g id="Logo_assets" data-name="Logo assets"><g id="Brand_mark" data-name="Brand mark"><path class="cls-1" d="M443.74,337.62l-27.16,47.05a139.52,139.52,0,0,1-120.82,69.75H241.43a139.52,139.52,0,0,1-120.82-69.75L93.45,337.62a139.52,139.52,0,0,1,0-139.51l27.16-47.05A139.53,139.53,0,0,1,241.43,81.3h54.33a139.53,139.53,0,0,1,120.82,69.76l27.16,47.05a139.23,139.23,0,0,1,8.55,17.55c0,.12.09.23.13.35a102.15,102.15,0,0,0,44.33-18.24c0-.14-.08-.28-.13-.43a237.8,237.8,0,0,0-33.29-67.58,240.67,240.67,0,0,0-52-53.48A239.3,239.3,0,0,0,98.65,437.08a239.43,239.43,0,0,0,398-98.69c.05-.15.09-.29.13-.43a102.15,102.15,0,0,0-44.33-18.24c0,.12-.09.23-.13.35A139.23,139.23,0,0,1,443.74,337.62Z"/><path class="cls-2" d="M360.4,347.4c-17,15.09-38.21,33.87-76.78,33.87h-23c-27.88,0-53.23-10.12-71.37-28.49-17.72-17.94-27.48-42.5-27.48-69.16V252.11c0-26.66,9.76-51.22,27.48-69.16,18.14-18.37,43.49-28.49,71.37-28.49h23c38.57,0,59.76,18.78,76.78,33.87,17.65,15.65,32.9,29.16,73.52,29.16a116.05,116.05,0,0,0,18.5-1.48c0-.12-.08-.23-.13-.35a139.23,139.23,0,0,0-8.55-17.55l-27.16-47.05A139.53,139.53,0,0,0,295.76,81.3H241.43a139.53,139.53,0,0,0-120.82,69.76L93.45,198.11a139.52,139.52,0,0,0,0,139.51l27.16,47.05a139.52,139.52,0,0,0,120.82,69.75h54.33a139.52,139.52,0,0,0,120.82-69.75l27.16-47.05a139.23,139.23,0,0,0,8.55-17.55c0-.12.09-.23.13-.35a116.05,116.05,0,0,0-18.5-1.48C393.3,318.24,378.05,331.75,360.4,347.4Z"/><path class="cls-2" d="M283.62,183h-23c-42.42,0-70.3,30.3-70.3,69.09v31.51c0,38.79,27.88,69.09,70.3,69.09h23c61.82,0,57-63,150.3-63a144.19,144.19,0,0,1,26.37,2.41,139.36,139.36,0,0,0,0-48.46,143.32,143.32,0,0,1-26.37,2.42C340.59,246.05,345.44,183,283.62,183Z"/><path class="cls-2" d="M513.91,315.13a130.21,130.21,0,0,0-53.62-23c0,.16-.05.32-.08.47a138.46,138.46,0,0,1-7.79,27.16A102.15,102.15,0,0,1,496.75,338c0,.14-.08.28-.13.43A237.8,237.8,0,0,1,463.33,406a240.67,240.67,0,0,1-52,53.48A239.3,239.3,0,0,1,98.65,98.65a239.43,239.43,0,0,1,398,98.69c.05.15.09.29.13.43A102.15,102.15,0,0,1,452.42,216a139.36,139.36,0,0,1,7.8,27.18c0,.15,0,.3.07.44a129.94,129.94,0,0,0,53.62-23c15.29-11.31,12.33-24.09,10-31.65C490.22,79.52,388.33,0,267.86,0,119.93,0,0,119.93,0,267.86S119.93,535.73,267.86,535.73c120.47,0,222.36-79.52,256-188.94C526.24,339.23,529.2,326.45,513.91,315.13Z"/><path class="cls-3" d="M452.42,216a116.05,116.05,0,0,1-18.5,1.48c-40.62,0-55.87-13.51-73.52-29.16-17-15.09-38.21-33.87-76.78-33.87h-23c-27.88,0-53.23,10.12-71.37,28.49-17.72,17.94-27.48,42.5-27.48,69.16v31.51c0,26.66,9.76,51.22,27.48,69.16,18.14,18.37,43.49,28.49,71.37,28.49h23c38.57,0,59.76-18.78,76.78-33.87,17.65-15.65,32.9-29.16,73.52-29.16a116.05,116.05,0,0,1,18.5,1.48,138.46,138.46,0,0,0,7.79-27.16c0-.15.06-.31.08-.47a144.19,144.19,0,0,0-26.37-2.41c-93.33,0-88.48,63-150.3,63h-23c-42.42,0-70.3-30.3-70.3-69.09V252.11c0-38.79,27.88-69.09,70.3-69.09h23c61.82,0,57,63,150.3,63a143.32,143.32,0,0,0,26.37-2.42c0-.14,0-.29-.07-.44A139.36,139.36,0,0,0,452.42,216Z"/><path class="cls-3" d="M452.42,216a116.05,116.05,0,0,1-18.5,1.48c-40.62,0-55.87-13.51-73.52-29.16-17-15.09-38.21-33.87-76.78-33.87h-23c-27.88,0-53.23,10.12-71.37,28.49-17.72,17.94-27.48,42.5-27.48,69.16v31.51c0,26.66,9.76,51.22,27.48,69.16,18.14,18.37,43.49,28.49,71.37,28.49h23c38.57,0,59.76-18.78,76.78-33.87,17.65-15.65,32.9-29.16,73.52-29.16a116.05,116.05,0,0,1,18.5,1.48,138.46,138.46,0,0,0,7.79-27.16c0-.15.06-.31.08-.47a144.19,144.19,0,0,0-26.37-2.41c-93.33,0-88.48,63-150.3,63h-23c-42.42,0-70.3-30.3-70.3-69.09V252.11c0-38.79,27.88-69.09,70.3-69.09h23c61.82,0,57,63,150.3,63a143.32,143.32,0,0,0,26.37-2.42c0-.14,0-.29-.07-.44A139.36,139.36,0,0,0,452.42,216Z"/></g></g></g></svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

@@ -0,0 +1,110 @@
import { IntegrationDefinition, z } from '@botpress/sdk'
import { scheduleEventInputSchema, scheduleEventOutputSchema } from 'definitions/actions'
import { inviteeEventOutputSchema } from 'definitions/events'
export default new IntegrationDefinition({
name: 'calendly',
title: 'Calendly',
version: '0.0.6',
readme: 'hub.md',
icon: 'icon.svg',
description: 'Schedule meetings and manage events using the Calendly scheduling platform.',
configuration: {
identifier: {
linkTemplateScript: 'linkTemplate.vrl',
},
schema: z.object({}),
},
configurations: {
manual: {
title: 'Manual Configuration',
description: 'Configure by manually supplying a Calendly Personal Access Token',
schema: z.object({
accessToken: z
.string()
.secret()
.min(1)
.describe('Your Calendly Personal Access Token')
.title('Personal Access Token'),
}),
},
},
actions: {
scheduleEvent: {
title: 'Schedule Calendly Event',
description: 'Generates a link for scheduling an event in Calendly',
input: { schema: scheduleEventInputSchema },
output: { schema: scheduleEventOutputSchema },
},
},
events: {
eventScheduled: {
title: 'Event Scheduled',
description: 'An event that triggers when an invitee fills out and submits a scheduling form',
schema: inviteeEventOutputSchema,
},
eventCanceled: {
title: 'Event Canceled',
description: 'An event that triggers when an invitee cancels a scheduled event',
schema: inviteeEventOutputSchema,
},
eventNoShowCreated: {
title: 'Event No Show Created',
description: 'An event that triggers when an invitee is marked as a no-show',
schema: inviteeEventOutputSchema,
},
eventNoShowDeleted: {
title: 'Event No Show Deleted',
description: 'An event that triggers when an invitee is unmarked as a no-show',
schema: inviteeEventOutputSchema,
},
},
secrets: {
OAUTH_CLIENT_ID: {
description: "The unique identifier that's used to initiate the OAuth flow",
},
OAUTH_CLIENT_SECRET: {
description: "A secret that's used to establish and refresh the OAuth authentication",
},
OAUTH_WEBHOOK_SIGNING_KEY: {
description: "The signing key used to validate Calendly's OAuth webhook request payloads",
},
},
states: {
configuration: {
type: 'integration',
schema: z.object({
oauth: z
.object({
refreshToken: z.string().describe('The refresh token for the integration').title('Refresh Token'),
accessToken: z.string().describe('The access token for the integration').title('Access Token'),
expiresAt: z
.number()
.min(0)
.describe('The expiry time of the access token represented as a Unix timestamp (milliseconds)')
.title('Expires At'),
})
.describe('The parameters used for accessing the Calendly API and refreshing the access token')
.title('OAuth Parameters')
.nullable(),
}),
},
webhooks: {
type: 'integration',
schema: z.object({
signingKey: z
.string()
.secret()
.describe('The signing key used for webhook event signatures')
.title('Webhook Signing Key'),
}),
},
},
__advanced: {
useLegacyZuiTransformer: true,
},
attributes: {
category: 'Business Operations',
repo: 'botpress',
},
})
+11
View File
@@ -0,0 +1,11 @@
webhookId = to_string!(.webhookId)
webhookUrl = to_string!(.webhookUrl)
env = to_string!(.env)
clientId = "wrG-UfzdRXU6f68l-QSZpDzpdVErsC0QUBmszx6WFLQ"
if env == "production" {
clientId = "GgPD_8KkRRtV8FhQrA5C6EsukYg9pxGLzML-FrxlCgg"
}
"https://auth.calendly.com/oauth/authorize?client_id={{ clientId }}&response_type=code&state={{ webhookId }}&redirect_uri={{ webhookUrl }}/oauth"
+20
View File
@@ -0,0 +1,20 @@
{
"name": "@botpresshub/calendly",
"scripts": {
"build": "bp add -y && bp build",
"check:type": "tsc --noEmit",
"check:bplint": "bp lint",
"test": "vitest --run"
},
"private": true,
"dependencies": {
"@botpress/client": "workspace:*",
"@botpress/common": "workspace:*",
"@botpress/sdk": "workspace:*",
"axios": "^1.11.0"
},
"devDependencies": {
"@botpress/cli": "workspace:*",
"@botpress/sdk": "workspace:*"
}
}
@@ -0,0 +1,5 @@
import { scheduleEvent } from './schedule-event'
export default {
scheduleEvent,
}
@@ -0,0 +1,33 @@
import { RuntimeError } from '@botpress/sdk'
import { CalendlyClient } from '../calendly-api'
import { parseError } from '../utils'
import type * as bp from '.botpress'
export const scheduleEvent: bp.IntegrationProps['actions']['scheduleEvent'] = async (props) => {
const { eventTypeUrl, conversationId } = props.input
try {
const calendlyClient = await CalendlyClient.create(props)
const currentUser = await calendlyClient.getCurrentUser()
const eventTypes = await calendlyClient.getEventTypesList(currentUser.resource.uri)
const eventType = eventTypes.collection.find((eventType) => eventType.scheduling_url === eventTypeUrl)
if (!eventType) {
throw new RuntimeError('Event type not found')
}
const resp = (await calendlyClient.createSingleUseSchedulingLink(eventType)).resource
const searchParams = new URLSearchParams({
utm_content: `conversationId=${conversationId}`,
})
return {
bookingUrl: `${resp.booking_url}?${searchParams}`,
ownerType: resp.owner_type,
owner: resp.owner,
}
} catch (thrown: unknown) {
throw parseError(thrown)
}
}
@@ -0,0 +1,112 @@
import { handleErrorsDecorator as handleErrors } from '@botpress/common'
import { RuntimeError } from '@botpress/sdk'
import axios, { type AxiosInstance } from 'axios'
import type { CommonHandlerProps, Result } from '../types'
import { type GetOAuthAccessTokenResp, getOAuthAccessTokenRespSchema } from './schemas'
import * as bp from '.botpress'
const AUTH_BASE_URL = 'https://auth.calendly.com' as const
export class CalendlyAuthClient {
private _axiosClient: AxiosInstance
public constructor() {
const { OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET } = bp.secrets
this._axiosClient = axios.create({
baseURL: AUTH_BASE_URL,
headers: {
Authorization: `Basic ${Buffer.from(`${OAUTH_CLIENT_ID}:${OAUTH_CLIENT_SECRET}`).toString('base64')}`,
},
})
}
private async _getAccessToken(params: GetAccessTokenParams): Promise<Result<GetOAuthAccessTokenResp>> {
// The Calendly API docs states that it only accepts
// `application/x-www-form-urlencoded` for this endpoint.
const formData = new FormData()
Object.entries(params).forEach(([key, value]) => formData.append(key, value))
const resp = await this._axiosClient.post('/oauth/token', formData)
if (resp.status < 200 || resp.status >= 300) {
return {
success: false,
error: new RuntimeError(
`Failed to retrieve access token w/${params.grant_type} | Invalid Status '${resp.status}'`
),
}
}
const result = getOAuthAccessTokenRespSchema.safeParse(resp.data)
if (!result.success) {
return {
success: false,
error: new RuntimeError(`Failed to retrieve access token w/${params.grant_type} | Schema Parse Failure`),
}
}
return { success: true, data: result.data }
}
@handleErrors('Failed to obtain Calendly OAuth access token from authorization code')
public async getAccessTokenWithCode(code: string): Promise<Result<GetOAuthAccessTokenResp>> {
return this._getAccessToken({
grant_type: 'authorization_code',
code,
redirect_uri: `${process.env.BP_WEBHOOK_URL}/oauth`,
})
}
@handleErrors('Failed to refresh Calendly OAuth access token')
public async getAccessTokenWithRefreshToken(refreshToken: string): Promise<Result<GetOAuthAccessTokenResp>> {
return this._getAccessToken({
grant_type: 'refresh_token',
refresh_token: refreshToken,
})
}
}
type GetAccessTokenParams =
| {
grant_type: 'authorization_code'
code: string
redirect_uri: string
}
| {
grant_type: 'refresh_token'
refresh_token: string
}
export const applyOAuthState = async ({ client, ctx }: CommonHandlerProps, resp: GetOAuthAccessTokenResp) => {
const { access_token, refresh_token, created_at, expires_in, owner: userUri } = resp
const { state } = await client.setState({
type: 'integration',
name: 'configuration',
id: ctx.integrationId,
payload: {
oauth: {
accessToken: access_token,
refreshToken: refresh_token,
expiresAt: (created_at + expires_in) * 1000,
},
},
})
if (!state.payload.oauth) {
throw new Error('Failed to store OAuth state')
}
return { oauth: state.payload.oauth, userUri }
}
export const exchangeAuthCodeForRefreshToken = async (props: bp.HandlerProps, oAuthCode: string): Promise<void> => {
const authClient = new CalendlyAuthClient()
const resp = await authClient.getAccessTokenWithCode(oAuthCode)
if (!resp.success) throw resp.error
await applyOAuthState(props, resp.data)
await props.client.configureIntegration({
identifier: props.ctx.webhookId,
})
}
@@ -0,0 +1,195 @@
import { RuntimeError } from '@botpress/sdk'
import axios, { type AxiosInstance } from 'axios'
import type { CommonHandlerProps } from '../types'
import { applyOAuthState, CalendlyAuthClient } from './auth'
import {
type CalendlyUri,
type CreateSchedulingLinkResp,
createSchedulingLinkRespSchema,
type CreateWebhookResp,
createWebhookRespSchema,
type EventType,
type GetCurrentUserResp,
getCurrentUserRespSchema,
type GetEventTypesListResp,
getEventTypesListRespSchema,
type GetWebhooksListResp,
getWebhooksListRespSchema,
} from './schemas'
import type { ContextOfType, RegisterWebhookParams, WebhooksListParams } from './types'
const API_BASE_URL = 'https://api.calendly.com' as const
// ------ Status Codes ------
const NO_CONTENT = 204 as const
export class CalendlyClient {
private _axiosClient: AxiosInstance
private constructor(accessToken: string) {
this._axiosClient = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
})
this._axiosClient.interceptors.request.use(async (config) => {
config.headers.Authorization = `Bearer ${accessToken}`
return config
})
}
public async getCurrentUser(): Promise<GetCurrentUserResp> {
const resp = await this._axiosClient.get<object>('/users/me')
const result = getCurrentUserRespSchema.safeParse(resp.data)
if (!result.success) {
throw new RuntimeError('Failed to get current user due to unexpected api response')
}
return result.data
}
public async getEventTypesList(userUri: CalendlyUri): Promise<GetEventTypesListResp> {
const searchParams = new URLSearchParams({ user: userUri })
const resp = await this._axiosClient.get<object>(`/event_types?${searchParams}`)
const result = getEventTypesListRespSchema.safeParse(resp.data)
if (!result.success) {
throw new RuntimeError('Failed to get event types list due to unexpected api response')
}
return result.data
}
public async getWebhooksList(params: WebhooksListParams): Promise<GetWebhooksListResp> {
const searchParams = new URLSearchParams({ ...params, count: '100' })
const resp = await this._axiosClient.get<object>(`/webhook_subscriptions?${searchParams}`)
const result = getWebhooksListRespSchema.safeParse(resp.data)
if (!result.success) {
throw new RuntimeError('Failed to get webhooks list due to unexpected api response')
}
return result.data
}
public async createWebhook(params: RegisterWebhookParams): Promise<CreateWebhookResp> {
const { webhookUrl, events, organization, scope, user, signingKey } = params
try {
const resp = await this._axiosClient.post<object>('/webhook_subscriptions', {
url: webhookUrl,
events,
organization,
user,
scope,
signing_key: signingKey,
})
const result = createWebhookRespSchema.safeParse(resp.data)
if (!result.success) {
throw new RuntimeError('Failed to create webhook due to unexpected api response', result.error)
}
return result.data
} catch (thrown: unknown) {
if (axios.isAxiosError(thrown)) {
if (thrown.status === 403) {
let errorMsg: string
const respData = thrown.response?.data
if (typeof respData === 'object' && 'message' in respData) {
errorMsg = respData.message
} else {
errorMsg =
"Either the user's account plan is insufficient (requires standard or above) or the user's account does not have the permission to register webhooks"
}
throw new RuntimeError(errorMsg, thrown)
}
throw new RuntimeError(thrown.message, thrown)
}
if (thrown instanceof RuntimeError) {
throw thrown
}
const error = thrown instanceof Error ? new RuntimeError(thrown.message) : new RuntimeError(String(thrown))
throw error
}
}
public async removeWebhook(webhookUri: CalendlyUri): Promise<boolean> {
const webhookUuid = _extractWebhookUuid(webhookUri)
const resp = await this._axiosClient.delete<object>(`/webhook_subscriptions/${webhookUuid}`)
return resp.status === NO_CONTENT
}
public async createSingleUseSchedulingLink(eventType: EventType): Promise<CreateSchedulingLinkResp> {
const resp = await this._axiosClient.post<object>('/scheduling_links', {
max_event_count: 1,
owner: eventType.uri,
owner_type: 'EventType',
})
const result = createSchedulingLinkRespSchema.safeParse(resp.data)
if (!result.success) {
throw new RuntimeError('Failed to create scheduling link due to unexpected api response')
}
return result.data
}
private static async _createFromManualConfig(ctx: ContextOfType<'manual'>) {
return new CalendlyClient(ctx.configuration.accessToken)
}
private static async _createFromOAuthConfig(props: CommonHandlerProps) {
const accessToken = await _getOAuthAccessToken(props)
return new CalendlyClient(accessToken)
}
public static async create(props: CommonHandlerProps): Promise<CalendlyClient> {
const { ctx } = props
switch (ctx.configurationType) {
case 'manual':
return this._createFromManualConfig(ctx)
case null:
return this._createFromOAuthConfig(props)
default:
ctx satisfies never
}
throw new RuntimeError(`Unsupported configuration type: ${props.ctx.configurationType}`)
}
}
const _extractWebhookUuid = (webhookUri: CalendlyUri) => {
const match = webhookUri.match(/\/webhook_subscriptions\/(.+)$/)
return match ? match[1] : null
}
const FIVE_MINUTES_IN_MS = 300000 as const
const _getOAuthAccessToken = async (props: CommonHandlerProps) => {
const { state } = await props.client.getOrSetState({
type: 'integration',
name: 'configuration',
id: props.ctx.integrationId,
payload: {
oauth: null,
},
})
let oauthState = state.payload.oauth
if (!oauthState) {
throw new RuntimeError('User authentication has not been completed')
}
const { expiresAt, refreshToken } = oauthState
if (expiresAt - FIVE_MINUTES_IN_MS <= Date.now()) {
const authClient = new CalendlyAuthClient()
const resp = await authClient.getAccessTokenWithRefreshToken(refreshToken)
if (!resp.success) throw resp.error
oauthState = (await applyOAuthState(props, resp.data)).oauth
}
return oauthState.accessToken
}
@@ -0,0 +1,174 @@
import { z } from '@botpress/sdk'
export const calendlyUri = z.string().url().brand('CalendlyUri')
export type CalendlyUri = z.infer<typeof calendlyUri>
export const uuidSchema = z.string().uuid()
export const paginationSchema = z
.object({
count: z.number(),
next_page: z.string().url().nullable(),
next_page_token: z.string().nullable(),
previous_page: z.string().url().nullable(),
previous_page_token: z.string().nullable(),
})
.passthrough()
export type Pagination = z.infer<typeof paginationSchema>
/** @see https://developer.calendly.com/api-docs/005832c83aeae-get-current-user */
export const getCurrentUserRespSchema = z
.object({
resource: z.object({
avatar_url: z.string().url().nullable(),
created_at: z.coerce.date(),
current_organization: calendlyUri,
email: z.string().email(),
locale: z.string(),
/** Human Readable format */
name: z.string(),
resource_type: z.string(),
scheduling_url: z.string().url(),
slug: z.string(),
time_notation: z.string(),
/** e.g. 'America/New_York' */
timezone: z.string(),
updated_at: z.coerce.date(),
/** The user's calendly URI */
uri: calendlyUri,
}),
})
.passthrough()
export type GetCurrentUserResp = z.infer<typeof getCurrentUserRespSchema>
export const eventQuestionSchema = z
.object({
name: z.string(),
type: z.string(),
position: z.number(),
enabled: z.boolean(),
required: z.boolean(),
answer_choices: z.array(z.string()),
include_other: z.boolean(),
})
.passthrough()
export type EventQuestion = z.infer<typeof eventQuestionSchema>
export const eventTypeSchema = z
.object({
uri: z.string().url(),
name: z.string().nullable(),
active: z.boolean(),
slug: z.string().nullable(),
scheduling_url: z.string().url(),
duration: z.number(),
duration_options: z.array(z.number()).nullable(),
kind: z.string(),
pooling_type: z.string().nullable(),
type: z.string(),
color: z.string(),
created_at: z.coerce.date(),
updated_at: z.coerce.date(),
internal_note: z.string().nullable(),
description_plain: z.string().nullable(),
description_html: z.string().nullable(),
profile: z
.object({
type: z.string(),
name: z.string(),
owner: z.string().url(),
})
.passthrough()
.nullable(),
secret: z.boolean(),
booking_method: z.string(),
custom_questions: z.array(eventQuestionSchema),
deleted_at: z.coerce.date().nullable(),
admin_managed: z.boolean(),
locations: z
.array(
z
.object({
kind: z.string(),
location: z.string().optional(),
additional_info: z.string().optional(),
phone_number: z.string().optional(),
})
.passthrough()
)
.nullable(),
position: z.number(),
locale: z.string(),
})
.passthrough()
export type EventType = z.infer<typeof eventTypeSchema>
/** @see https://developer.calendly.com/api-docs/25a4ece03c1bc-list-user-s-event-types */
export const getEventTypesListRespSchema = z
.object({
collection: z.array(eventTypeSchema),
pagination: paginationSchema,
})
.passthrough()
export type GetEventTypesListResp = z.infer<typeof getEventTypesListRespSchema>
/** @see https://developer.calendly.com/api-docs/4b8195084e287-create-single-use-scheduling-link */
export const createSchedulingLinkRespSchema = z
.object({
resource: z
.object({
booking_url: z.string().url(),
owner: calendlyUri,
owner_type: z.string(),
})
.passthrough(),
})
.passthrough()
export type CreateSchedulingLinkResp = z.infer<typeof createSchedulingLinkRespSchema>
export const webhookDetailsSchema = z
.object({
callback_url: z.string(),
created_at: z.coerce.date(),
creator: z.string(),
events: z.array(z.string()),
group: z.null(),
organization: calendlyUri,
retry_started_at: z.coerce.date().nullable(),
scope: z.string(),
state: z.string(),
updated_at: z.coerce.date(),
uri: calendlyUri,
user: calendlyUri.nullable(),
})
.passthrough()
export type WebhookDetails = z.infer<typeof webhookDetailsSchema>
/** @see https://developer.calendly.com/api-docs/faac832d7c57d-list-webhook-subscriptions */
export const getWebhooksListRespSchema = z
.object({
collection: z.array(webhookDetailsSchema),
pagination: paginationSchema,
})
.passthrough()
export type GetWebhooksListResp = z.infer<typeof getWebhooksListRespSchema>
/** @see https://developer.calendly.com/api-docs/c1ddc06ce1f1b-create-webhook-subscription */
export const createWebhookRespSchema = z
.object({
resource: webhookDetailsSchema,
})
.passthrough()
export type CreateWebhookResp = z.infer<typeof createWebhookRespSchema>
export const getOAuthAccessTokenRespSchema = z.object({
access_token: z.string().min(1),
token_type: z.string().min(1),
expires_in: z.number().min(0),
refresh_token: z.string().min(1),
scope: z.string().min(1),
created_at: z.number().min(0),
owner: calendlyUri,
organization: calendlyUri,
})
export type GetOAuthAccessTokenResp = z.infer<typeof getOAuthAccessTokenRespSchema>
@@ -0,0 +1,41 @@
import type { CalendlyUri } from './schemas'
import type * as bp from '.botpress'
export type WebhooksListParams =
| {
scope: 'organization'
organization: CalendlyUri
}
| {
scope: 'user'
organization: CalendlyUri
user: CalendlyUri
}
type WebhookScopes = 'organization' | 'user'
type WebhookEvents<Scope extends WebhookScopes = WebhookScopes> =
| 'invitee.created'
| 'invitee.canceled'
| 'invitee_no_show.created'
| 'invitee_no_show.deleted'
| (Scope extends 'organization' ? 'routing_form_submission.created' : never)
export type RegisterWebhookParams =
| {
scope: 'organization'
organization: CalendlyUri
events: WebhookEvents<'organization'>[]
user?: undefined
webhookUrl: string
signingKey?: string
}
| {
scope: 'user'
organization: CalendlyUri
user: CalendlyUri
events: WebhookEvents<'user'>[]
webhookUrl: string
signingKey?: string
}
export type ContextOfType<T extends bp.Context['configurationType']> = Extract<bp.Context, { configurationType: T }>
+47
View File
@@ -0,0 +1,47 @@
import { generateRedirection, getInterstitialUrl } from '@botpress/common/src/oauth-wizard/interstitial'
import { exchangeAuthCodeForRefreshToken } from './calendly-api/auth'
import { dispatchIntegrationEvent } from './webhooks/event-dispatcher'
import { parseWebhookEvent, verifyWebhookSignature } from './webhooks/webhook-utils'
import * as bp from '.botpress'
const _isOauthRequest = ({ req }: bp.HandlerProps) => req.path === '/oauth'
export const handler = async (props: bp.HandlerProps) => {
if (_isOauthRequest(props)) {
try {
const searchParams = new URLSearchParams(props.req.query)
const error = searchParams.get('error')
if (error) {
throw new Error(`${error} - ${searchParams.get('error_description') ?? ''}`)
}
const oAuthCode = searchParams.get('code')
if (!oAuthCode) {
throw new Error('Authorization code not present in OAuth callback')
}
await exchangeAuthCodeForRefreshToken(props, oAuthCode)
return generateRedirection(getInterstitialUrl(true))
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
const errorMessage = 'OAuth error: ' + msg
props.logger.forBot().error(errorMessage)
return generateRedirection(getInterstitialUrl(false, errorMessage))
}
}
const signatureResult = await verifyWebhookSignature(props)
if (!signatureResult.success) {
props.logger.forBot().error(signatureResult.error.message, signatureResult.error)
return {}
}
const result = parseWebhookEvent(props)
if (!result.success) {
props.logger.forBot().error(result.error.message, result.error)
return {}
}
await dispatchIntegrationEvent(props, result.data)
return {}
}
+12
View File
@@ -0,0 +1,12 @@
import actions from './actions'
import { handler } from './handler'
import { register, unregister } from './setup'
import * as bp from '.botpress'
export default new bp.Integration({
register,
unregister,
actions,
channels: {},
handler,
})
+55
View File
@@ -0,0 +1,55 @@
import { CalendlyClient } from './calendly-api'
import type { GetCurrentUserResp, WebhookDetails } from './calendly-api/schemas'
import { getWebhookSigningKey } from './webhooks/signing-key'
import * as bp from '.botpress'
const performUnregistration = async (
calendlyClient: CalendlyClient,
userResp: GetCurrentUserResp,
webhookUrl: string
) => {
const { current_organization: organizationUri, uri: userUri } = userResp.resource
// This will break if for some reason the calendly account has over 100 webhooks
const webhooksToDelete: WebhookDetails[] = (
await calendlyClient.getWebhooksList({
scope: 'user',
organization: organizationUri,
user: userUri,
})
).collection.filter((webhook) => webhook.callback_url === webhookUrl)
for (const webhook of webhooksToDelete) {
await calendlyClient.removeWebhook(webhook.uri)
}
}
export const unregister: bp.Integration['unregister'] = async (props) => {
const calendlyClient = await CalendlyClient.create(props)
const currentUser = await calendlyClient.getCurrentUser()
await performUnregistration(calendlyClient, currentUser, props.webhookUrl)
}
export const register: bp.Integration['register'] = async (props) => {
const calendlyClient = await CalendlyClient.create(props)
const userResp = await calendlyClient.getCurrentUser()
try {
// Simply checking if webhook subscriptions exists then skipping following logic won't work here.
// This is because such an approach may lead to a de-synchronization of the webhook signing key.
await performUnregistration(calendlyClient, userResp, props.webhookUrl)
} catch {
// Do nothing since if it's the first time there's nothing to unregister
}
const { current_organization: organizationUri, uri: userUri } = userResp.resource
await calendlyClient.createWebhook({
webhookUrl: props.webhookUrl,
events: ['invitee.created', 'invitee.canceled', 'invitee_no_show.created', 'invitee_no_show.deleted'],
organization: organizationUri,
user: userUri,
scope: 'user',
signingKey: await getWebhookSigningKey(props),
})
}
+11
View File
@@ -0,0 +1,11 @@
import type * as bp from '.botpress'
export type Supplier<T> = () => T
export type Result<T> = { success: true; data: T } | { success: false; error: Error }
export type CommonHandlerProps = {
ctx: bp.Context
client: bp.Client
logger: bp.Logger
}
+37
View File
@@ -0,0 +1,37 @@
import { RuntimeError, z } from '@botpress/sdk'
import axios from 'axios'
import type { Result } from './types'
const _isZodError = (error: any): error is z.ZodError => {
return error && typeof error === 'object' && z.is.zuiError(error) && 'errors' in error
}
export function parseError(thrown: unknown): RuntimeError {
if (axios.isAxiosError(thrown)) {
return new RuntimeError(thrown.response?.data?.message || thrown.message)
}
if (_isZodError(thrown)) {
return new RuntimeError(thrown.errors.map((e) => e.message).join(', '))
}
if (thrown instanceof RuntimeError) {
return thrown
}
return thrown instanceof Error ? new RuntimeError(thrown.message) : new RuntimeError(String(thrown))
}
export const safeParseJson = (json: string): Result<unknown> => {
try {
return {
success: true,
data: JSON.parse(json),
} as const
} catch (thrown: unknown) {
return {
success: false,
error: thrown instanceof Error ? thrown : new Error(String(thrown)),
} as const
}
}
@@ -0,0 +1,18 @@
import * as inviteeHandlers from './event-handlers'
import type { InviteeEvent } from './schemas'
import type * as bp from '.botpress'
export const dispatchIntegrationEvent = async (props: bp.HandlerProps, webhookEvent: InviteeEvent) => {
switch (webhookEvent.event) {
case 'invitee.created':
return await inviteeHandlers.handleInviteeEvent(props, 'eventScheduled', webhookEvent)
case 'invitee.canceled':
return await inviteeHandlers.handleInviteeEvent(props, 'eventCanceled', webhookEvent)
case 'invitee_no_show.created':
return await inviteeHandlers.handleInviteeEvent(props, 'eventNoShowCreated', webhookEvent)
case 'invitee_no_show.deleted':
return await inviteeHandlers.handleInviteeEvent(props, 'eventNoShowDeleted', webhookEvent)
default:
return null
}
}
@@ -0,0 +1,68 @@
import { z } from '@botpress/sdk'
import { safeParseJson } from 'src/utils'
import { CalendlyClient } from '../calendly-api'
import type { InviteeEvent } from './schemas'
import type * as bp from '.botpress'
const trackingParameterSchema = z.union([z.string(), z.array(z.string())])
const _parseTrackingParameter = (trackingParameter: string | null): string[] => {
if (trackingParameter === null) return []
const parseResult = safeParseJson(trackingParameter)
if (!parseResult.success) {
return [trackingParameter]
}
const zodResult = trackingParameterSchema.safeParse(parseResult.data)
if (!zodResult.success) {
return [String(parseResult.data)]
}
const { data } = zodResult
return Array.isArray(data) ? data : [data]
}
export const handleInviteeEvent = async (
props: bp.HandlerProps,
eventType: keyof bp.events.Events,
event: InviteeEvent
) => {
const { start_time, end_time, location, name: eventName, uri: scheduledEventUri } = event.payload.scheduled_event
const calendlyClient = await CalendlyClient.create(props)
const currentUser = await calendlyClient.getCurrentUser()
const { tracking } = event.payload
const utmContentValues = _parseTrackingParameter(tracking.utm_content)
if (utmContentValues.length === 0) {
props.logger.forBot().warn('The event did not have an associated utm_content value with a conversation id')
}
const conversationIdPattern = /conversationId=([\w]+)/
const conversationId =
utmContentValues
.find((contentValue) => conversationIdPattern.test(contentValue))
?.match(conversationIdPattern)?.[1] ?? null
if (!conversationId) {
props.logger.forBot().warn('Could not extract the conversation id from the utm_content parameter')
}
return await props.client.createEvent({
type: eventType,
payload: {
scheduledEventUri,
eventName: eventName ?? `Meeting between ${currentUser.resource.name} and ${event.payload.name}`,
startTime: start_time.toISOString(),
endTime: end_time.toISOString(),
locationType: location.type,
organizerName: currentUser.resource.name,
organizerEmail: currentUser.resource.email,
inviteeName: event.payload.name,
inviteeEmail: event.payload.email,
conversationId,
},
})
}
@@ -0,0 +1,143 @@
import { z } from '@botpress/sdk'
import { calendlyLocationSchema } from './locations'
const questionsAndAnswerSchema = z
.object({
question: z.string(),
answer: z.string(),
position: z.number(),
})
.passthrough()
const trackingSchema = z
.object({
utm_campaign: z.string().nullable(),
utm_source: z.string().nullable(),
utm_medium: z.string().nullable(),
utm_content: z.string().nullable(),
utm_term: z.string().nullable(),
salesforce_uuid: z.string().nullable(),
})
.passthrough()
const cancellationSchema = z
.object({
canceled_by: z.string(),
reason: z.string().nullable(),
canceler_type: z.string(),
created_at: z.coerce.date(),
})
.passthrough()
const paymentSchema = z
.object({
external_id: z.string(),
provider: z.string(),
amount: z.number().min(0),
currency: z.string(),
terms: z.string().nullable(),
successful: z.boolean(),
})
.passthrough()
const eventMembershipSchema = z
.object({
user: z.string().url(),
user_email: z.string().email(),
user_name: z.string(),
})
.passthrough()
const inviteesCounterSchema = z
.object({
total: z.number(),
active: z.number(),
limit: z.number(),
})
.passthrough()
const eventGuestSchema = z
.object({
email: z.string().email(),
created_at: z.coerce.date(),
updated_at: z.coerce.date(),
})
.passthrough()
const scheduledEventSchema = z
.object({
uri: z.string().url(),
name: z.string().nullable(),
meeting_notes_plain: z.string().nullable().optional(),
meeting_notes_html: z.string().nullable().optional(),
status: z.string(),
start_time: z.coerce.date(),
end_time: z.coerce.date(),
event_type: z.string().url(),
location: calendlyLocationSchema,
invitees_counter: inviteesCounterSchema,
created_at: z.coerce.date(),
updated_at: z.coerce.date(),
event_memberships: z.array(eventMembershipSchema),
event_guests: z.array(eventGuestSchema),
cancellation: cancellationSchema.optional(),
})
.passthrough()
const inviteeEventPayloadSchema = z
.object({
uri: z.string().url(),
email: z.string().email(),
first_name: z.string().nullable(),
last_name: z.string().nullable(),
name: z.string(),
status: z.string(),
questions_and_answers: z.array(questionsAndAnswerSchema),
timezone: z.string().nullable(),
event: z.string().url(),
created_at: z.coerce.date(),
updated_at: z.coerce.date(),
tracking: trackingSchema,
text_reminder_number: z.string().nullable(),
rescheduled: z.boolean(),
old_invitee: z.string().url().nullable(),
new_invitee: z.string().url().nullable(),
cancel_url: z.string().url(),
reschedule_url: z.string().url(),
routing_form_submission: z.string().url().nullable(),
cancellation: cancellationSchema.optional(),
payment: paymentSchema.nullable(),
no_show: z
.object({
uri: z.string().url(),
created_at: z.coerce.date(),
})
.passthrough()
.nullable(),
reconfirmation: z
.object({
created_at: z.coerce.date(),
confirmed_at: z.coerce.date().nullable(),
})
.passthrough()
.nullable(),
scheduling_method: z.string().nullable(),
invitee_scheduled_by: z.string().nullable(),
scheduled_event: scheduledEventSchema,
})
.passthrough()
export const inviteeEventSchema = z
.object({
event: z.union([
z.literal('invitee.created'),
z.literal('invitee.canceled'),
z.literal('invitee_no_show.created'),
z.literal('invitee_no_show.deleted'),
]),
created_at: z.coerce.date(),
created_by: z.string().url(),
payload: inviteeEventPayloadSchema,
})
.passthrough()
export type InviteeEvent = z.infer<typeof inviteeEventSchema>
@@ -0,0 +1,169 @@
import { z } from '@botpress/sdk'
const inPersonMeetingSchema = z
.object({
type: z.literal('physical'),
location: z.string().describe('The physical location specified by the event host (publisher)'),
additional_info: z.string().optional(),
})
.passthrough()
const outboundCallSchema = z
.object({
type: z.literal('outbound_call'),
location: z
.string()
.nullable()
.describe('The phone number the event host (publisher) will use to call the invitee'),
})
.passthrough()
const inboundCallSchema = z
.object({
type: z.literal('inbound_call'),
location: z.string().describe('The phone number the invitee will use to call the event host (publisher)'),
additional_info: z.string().optional(),
})
.passthrough()
const googleConferenceSchema = z
.object({
type: z.literal('google_conference'),
status: z.string().nullable(),
join_url: z.string().url().nullable(),
})
.passthrough()
const zoomConferenceSchema = z
.object({
type: z.literal('zoom'),
status: z.string().nullable(),
join_url: z.string().url().nullable(),
data: z
.object({
id: z.string().optional().describe('The Zoom meeting ID'),
settings: z
.object({
global_dial_in_numbers: z
.array(
z
.object({
number: z.string().optional(),
country: z.string().optional().describe('Country Code'),
type: z.string().optional(),
city: z.string().optional(),
country_name: z.string().optional(),
})
.passthrough()
)
.optional()
.describe('Global dial-in numbers for the Zoom meeting'),
})
.passthrough()
.optional(),
extra: z
.object({
intl_numbers_url: z.string().url().optional(),
})
.passthrough()
.optional(),
password: z.string().optional(),
})
.passthrough()
.nullable(),
})
.passthrough()
const goToMeetingConferenceSchema = z
.object({
type: z.literal('gotomeeting'),
status: z.string().nullable(),
join_url: z.string().url().nullable(),
data: z
.object({
uniqueMeetingId: z.number().optional(),
conferenceCallInfo: z.string().optional(),
})
.passthrough()
.nullable(),
})
.passthrough()
const microsoftTeamsConferenceSchema = z
.object({
type: z.literal('microsoft_teams_conference'),
status: z.string().nullable(),
join_url: z.string().url().nullable(),
data: z
.object({
id: z.string().optional(),
audioConferencing: z
.object({
conferenceId: z.string().optional(),
dialinUrl: z.string().url().optional(),
tollNumber: z.string().optional(),
})
.passthrough()
.nullable()
.optional(),
})
.passthrough()
.nullable(),
})
.passthrough()
const customLocationSchema = z
.object({
type: z.literal('custom'),
location: z.string().nullable(),
})
.passthrough()
const inviteeSpecifiedLocationSchema = z
.object({
type: z.literal('ask_invitee'),
location: z.string(),
})
.passthrough()
const webexConferenceSchema = z
.object({
type: z.literal('webex_conference'),
status: z.string().nullable(),
join_url: z.string().url().nullable(),
data: z
.object({
id: z.string(),
telephony: z
.object({
callInNumbers: z.array(
z
.object({
label: z.string(),
callInNumber: z.string(),
tollType: z.string(),
})
.passthrough()
),
})
.passthrough(),
password: z.string(),
})
.passthrough()
.nullable(),
})
.passthrough()
export const calendlyLocationSchema = z.union([
inPersonMeetingSchema,
outboundCallSchema,
inboundCallSchema,
googleConferenceSchema,
zoomConferenceSchema,
goToMeetingConferenceSchema,
microsoftTeamsConferenceSchema,
customLocationSchema,
inviteeSpecifiedLocationSchema,
webexConferenceSchema,
])
export type CalendlyLocation = z.infer<typeof calendlyLocationSchema>
@@ -0,0 +1,42 @@
import crypto from 'crypto'
import { CommonHandlerProps } from '../types'
import * as bp from '.botpress'
const SIGNING_KEY_BYTES = 32
export const getWebhookSigningKey = async ({ client, ctx }: CommonHandlerProps): Promise<string> => {
switch (ctx.configurationType) {
case 'manual':
return await _getManualPatSigningKey(client, ctx)
case null:
return _getOAuthSigningKey(client, ctx)
default:
// @ts-ignore
throw new Error(`Unsupported configuration type: ${props.ctx.configurationType}`)
}
}
/** Generate a 256-bit signing key (For Manual PAT Authentication). */
function _generateSigningKey(): string {
const raw = crypto.randomBytes(SIGNING_KEY_BYTES)
return raw.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
const _getSigningKey = async (client: bp.Client, ctx: bp.Context, fallbackValue: string) => {
const { state } = await client.getOrSetState({
type: 'integration',
name: 'webhooks',
id: ctx.integrationId,
payload: {
signingKey: fallbackValue,
},
})
return state.payload.signingKey
}
const _getOAuthSigningKey = async (client: bp.Client, ctx: bp.Context) =>
_getSigningKey(client, ctx, bp.secrets.OAUTH_WEBHOOK_SIGNING_KEY)
const _getManualPatSigningKey = async (client: bp.Client, ctx: bp.Context) =>
_getSigningKey(client, ctx, _generateSigningKey())
@@ -0,0 +1,99 @@
import crypto from 'crypto'
import type { Result } from '../types'
import { safeParseJson } from '../utils'
import { type InviteeEvent, inviteeEventSchema } from './schemas'
import { getWebhookSigningKey } from './signing-key'
import type * as bp from '.botpress'
const MS_PER_SECOND = 1000 as const
const MS_PER_MINUTE = 60 * MS_PER_SECOND
const WEBHOOK_SIGNATURE_TOLERANCE_MS = 3 * MS_PER_MINUTE
const WEBHOOK_SIGNATURE_HEADER = 'calendly-webhook-signature' as const
export const parseWebhookEvent = (props: bp.HandlerProps): Result<InviteeEvent> => {
const { body } = props.req
if (!body?.trim()) {
return { success: false, error: new Error('Received empty webhook payload') }
}
const parseResult = safeParseJson(body)
if (!parseResult.success) {
return { success: false, error: new Error('Unable to parse Calendly Webhook Payload', parseResult.error) }
}
const zodResult = inviteeEventSchema.safeParse(parseResult.data)
if (!zodResult.success) {
props.logger.error('Webhook handler received unexpected payload', zodResult.error)
return { success: false, error: new Error('Invalid webhook payload structure', zodResult.error) }
}
return {
success: true,
data: zodResult.data,
}
}
export const verifyWebhookSignature = async (
props: bp.HandlerProps
): Promise<{ success: true } | { success: false; error: Error }> => {
const headerResult = _parseSignatureHeader(props.req.headers)
if (!headerResult.success) {
return headerResult
}
const { timestamp, signature } = headerResult.data
const signingKey = await getWebhookSigningKey(props)
const payload = `${timestamp}.${props.req.body}`
const expected = crypto.createHmac('sha256', signingKey).update(payload, 'utf8').digest('hex')
if (expected !== signature) {
return { success: false, error: new Error('Webhook event did not match the expected signature') }
}
// Prevent replay attacks
if (timestamp * MS_PER_SECOND < Date.now() - WEBHOOK_SIGNATURE_TOLERANCE_MS) {
return { success: false, error: new Error('Webhook event was received outside of the accepted tolerance zone') }
}
return { success: true }
}
const _malformedSignatureHeaderError = () => new Error('Calendly webhook signature header is malformed')
type ParseSignatureHeaderData = {
timestamp: number
signature: string
}
const TIMESTAMP_PREFIX = 't=' as const
const SIGNATURE_PREFIX = 'v1=' as const
const _parseSignatureHeader = (headers: Record<string, string | undefined>): Result<ParseSignatureHeaderData> => {
const signatureHeader = headers[WEBHOOK_SIGNATURE_HEADER]?.trim() ?? ''
if (signatureHeader.length === 0) {
return { success: false, error: new Error('Calendly webhook signature header is missing from the request') }
}
const signatureHeaderParts = signatureHeader.split(',')
if (signatureHeaderParts.length !== 2) {
return { success: false, error: _malformedSignatureHeaderError() }
}
const [rawTimestamp, rawSignature] = signatureHeaderParts as [string, string]
if (!rawTimestamp.startsWith(TIMESTAMP_PREFIX) || !rawSignature.startsWith(SIGNATURE_PREFIX)) {
return { success: false, error: _malformedSignatureHeaderError() }
}
const timestampSeconds = parseInt(rawTimestamp.slice(TIMESTAMP_PREFIX.length), 10)
if (isNaN(timestampSeconds)) {
return { success: false, error: _malformedSignatureHeaderError() }
}
return {
success: true,
data: {
timestamp: timestampSeconds,
signature: rawSignature.slice(SIGNATURE_PREFIX.length),
},
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"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