Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 59 additions & 8 deletions integrations/calendly/integration.definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,24 @@ export default new IntegrationDefinition({
icon: 'icon.svg',
description: 'Schedule meetings and manage events using the Calendly scheduling platform.',
configuration: {
schema: z.object({
accessToken: z
.string()
.secret()
.min(1)
.describe('Your Calendly Personal Access Token')
.title('Personal Access Token'),
}),
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: {
Expand Down Expand Up @@ -49,4 +59,45 @@ export default new IntegrationDefinition({
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'),
}),
},
},
})
11 changes: 11 additions & 0 deletions integrations/calendly/linkTemplate.vrl
Original file line number Diff line number Diff line change
@@ -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"
11 changes: 4 additions & 7 deletions integrations/calendly/src/actions/schedule-event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,13 @@ import { parseError } from '../utils'
import type * as bp from '.botpress'

export const scheduleEvent: bp.IntegrationProps['actions']['scheduleEvent'] = async (props) => {
const { ctx, input } = props
const { eventTypeUrl, conversationId } = props.input

try {
const calendlyClient = new CalendlyClient(ctx.configuration.accessToken)

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 === input.eventTypeUrl)
const eventType = eventTypes.collection.find((eventType) => eventType.scheduling_url === eventTypeUrl)

if (!eventType) {
throw new RuntimeError('Event type not found')
Expand All @@ -20,9 +19,7 @@ export const scheduleEvent: bp.IntegrationProps['actions']['scheduleEvent'] = as
const resp = (await calendlyClient.createSingleUseSchedulingLink(eventType)).resource

const searchParams = new URLSearchParams({
utm_source: 'chatbot',
utm_medium: 'conversation',
utm_content: `id=${input.conversationId}`,
utm_content: `conversationId=${conversationId}`,
})

return {
Expand Down
125 changes: 125 additions & 0 deletions integrations/calendly/src/calendly-api/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { RuntimeError } from '@botpress/sdk'
import axios, { type AxiosInstance } from 'axios'
import type { CommonHandlerProps, Result } from '../types'
import { type CalendlyUri, type GetOAuthAccessTokenResp, getOAuthAccessTokenRespSchema, uuidSchema } 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 }
}

public async getAccessTokenWithCode(code: string): Promise<Result<GetOAuthAccessTokenResp>> {
return this._getAccessToken({
grant_type: 'authorization_code',
code,
redirect_uri: `${process.env.BP_WEBHOOK_URL}/oauth`,
})
}

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
}

const _extractUserUuid = (userUri: CalendlyUri): string => {
const match = userUri.match(/\/users\/(.+)$/)

if (!match) {
throw new Error('Failed to extract user UUID from URI')
}

const parsed = uuidSchema.safeParse(match[1])
if (!parsed.success) {
throw new Error('Failed to extract user UUID from URI')
}

return parsed.data
}

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

const { userUri } = await applyOAuthState(props, resp.data)

const userId = _extractUserUuid(userUri)
await props.client.configureIntegration({
identifier: userId,
})
}
102 changes: 63 additions & 39 deletions integrations/calendly/src/calendly-api/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { RuntimeError } from '@botpress/sdk'
import axios, { AxiosInstance } from 'axios'
import axios, { type AxiosInstance } from 'axios'
import type { CommonHandlerProps } from '../types'
import { applyOAuthState, CalendlyAuthClient } from './auth'
import {
type CalendlyUri,
type CreateSchedulingLinkResp,
Expand All @@ -14,23 +16,28 @@ import {
type GetWebhooksListResp,
getWebhooksListRespSchema,
} from './schemas'
import type { ContextOfType, RegisterWebhookParams, WebhooksListParams } from './types'

const BASE_URL = 'https://api.calendly.com' as const
const API_BASE_URL = 'https://api.calendly.com' as const

// ------ Status Codes ------
const NO_CONTENT = 204 as const

export class CalendlyClient {
private _axiosClient: AxiosInstance

public constructor(accessToken: string) {
private constructor(accessToken: string) {
this._axiosClient = axios.create({
baseURL: BASE_URL,
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})

this._axiosClient.interceptors.request.use(async (config) => {
config.headers.Authorization = `Bearer ${accessToken}`
return config
})
}

public async getCurrentUser(): Promise<GetCurrentUserResp> {
Expand Down Expand Up @@ -64,13 +71,14 @@ export class CalendlyClient {
}

public async createWebhook(params: RegisterWebhookParams): Promise<CreateWebhookResp> {
const { webhookUrl, events, organization, scope, user } = params
const { webhookUrl, events, organization, scope, user, signingKey } = params
const resp = await this._axiosClient.post<object>('/webhook_subscriptions', {
url: webhookUrl,
events,
organization,
user,
scope,
signing_key: signingKey,
})

try {
Expand Down Expand Up @@ -99,44 +107,60 @@ export class CalendlyClient {
throw new RuntimeError('Failed to create scheduling link due to unexpected api response')
}
}

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
}

type WebhooksListParams =
| {
scope: 'organization'
organization: CalendlyUri
}
| {
scope: 'user'
organization: CalendlyUri
user: CalendlyUri
}
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')
}

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)

type RegisterWebhookParams =
| {
scope: 'organization'
organization: CalendlyUri
events: WebhookEvents<'organization'>[]
user?: undefined
webhookUrl: string
}
| {
scope: 'user'
organization: CalendlyUri
user: CalendlyUri
events: WebhookEvents<'user'>[]
webhookUrl: string
}
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
}
Loading
Loading