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,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),
},
}
}