chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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 }>
|
||||
@@ -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 {}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user