chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
import { RuntimeError } from '@botpress/sdk'
|
||||
import { CommonHandlerProps } from '../types'
|
||||
import { DocusignAuthClient } from './auth'
|
||||
import { GetAccessTokenResp, UserAccount } from './schemas'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const MS_PER_MINUTE = 60000
|
||||
export const MS_PER_HOUR = MS_PER_MINUTE * 60
|
||||
|
||||
export const applyOAuthState = async ({ client, ctx }: CommonHandlerProps, tokenResp: GetAccessTokenResp) => {
|
||||
const { accessToken, refreshToken, expiresAt, tokenType } = tokenResp
|
||||
|
||||
const { state } = await client.setState({
|
||||
type: 'integration',
|
||||
name: 'configuration',
|
||||
id: ctx.integrationId,
|
||||
payload: {
|
||||
oauth: {
|
||||
accessToken,
|
||||
tokenType,
|
||||
refreshToken,
|
||||
expiresAt,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!state.payload.oauth) {
|
||||
throw new Error('Failed to store OAuth state')
|
||||
}
|
||||
|
||||
return state.payload.oauth
|
||||
}
|
||||
|
||||
const OAUTH_TIMEOUT_BUFFER = MS_PER_MINUTE * 5
|
||||
export const getOAuthState = async (props: CommonHandlerProps, authClient?: DocusignAuthClient) => {
|
||||
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 - OAUTH_TIMEOUT_BUFFER <= Date.now()) {
|
||||
authClient ??= DocusignAuthClient.create(props)
|
||||
const tokenResp = await authClient.getAccessTokenWithRefreshToken(refreshToken)
|
||||
if (!tokenResp.success) throw tokenResp.error
|
||||
|
||||
oauthState = await applyOAuthState(props, tokenResp.data)
|
||||
}
|
||||
|
||||
return oauthState
|
||||
}
|
||||
|
||||
const _findAccount = (accountsList: UserAccount[], explicitAccountId: string | undefined): UserAccount => {
|
||||
let account: UserAccount | null = null
|
||||
|
||||
if (explicitAccountId) {
|
||||
account = accountsList.find(({ account_id }) => account_id === explicitAccountId) ?? null
|
||||
|
||||
if (!account) {
|
||||
throw new RuntimeError('An account with the specified API Account ID does not exist or is not owned by this user')
|
||||
}
|
||||
} else {
|
||||
account = accountsList.find(({ is_default }: UserAccount) => is_default) ?? accountsList[0]!
|
||||
}
|
||||
|
||||
return account
|
||||
}
|
||||
|
||||
const ACCOUNT_REFRESH_AFTER = MS_PER_HOUR * 24
|
||||
export const refreshAccountState = async (props: CommonHandlerProps) => {
|
||||
const authClient = DocusignAuthClient.create(props)
|
||||
|
||||
const { accessToken, tokenType } = await getOAuthState(props, authClient)
|
||||
|
||||
const userInfoResp = await authClient.getUserInfo(accessToken, tokenType)
|
||||
if (!userInfoResp.success) throw userInfoResp.error
|
||||
|
||||
const { client, ctx } = props
|
||||
const { accounts } = userInfoResp.data
|
||||
|
||||
const explicitAccountId = ctx.configuration.accountId
|
||||
const account = _findAccount(accounts, explicitAccountId)
|
||||
|
||||
const refreshAt = !explicitAccountId?.trim() ? Date.now() + ACCOUNT_REFRESH_AFTER : null
|
||||
const { state } = await client.setState({
|
||||
type: 'integration',
|
||||
name: 'account',
|
||||
id: ctx.integrationId,
|
||||
payload: {
|
||||
account: {
|
||||
id: account.account_id,
|
||||
baseUri: account.base_uri,
|
||||
refreshAt,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!state.payload.account) {
|
||||
throw new RuntimeError('Failed to store account state')
|
||||
}
|
||||
|
||||
return state.payload.account
|
||||
}
|
||||
|
||||
export const getAccountState = async (props: CommonHandlerProps) => {
|
||||
const { state } = await props.client.getOrSetState({
|
||||
type: 'integration',
|
||||
name: 'account',
|
||||
id: props.ctx.integrationId,
|
||||
payload: {
|
||||
account: null,
|
||||
},
|
||||
})
|
||||
|
||||
let hasAccountChanged = false
|
||||
let accountState = state.payload.account
|
||||
if (!accountState || (accountState.refreshAt && accountState.refreshAt <= Date.now())) {
|
||||
const prevAccountState = accountState
|
||||
accountState = await refreshAccountState(props)
|
||||
|
||||
if (accountState.id !== prevAccountState?.id) {
|
||||
hasAccountChanged = true
|
||||
}
|
||||
}
|
||||
|
||||
return { account: accountState, hasChanged: hasAccountChanged }
|
||||
}
|
||||
|
||||
export const exchangeAuthCodeForRefreshToken = async (props: bp.HandlerProps, oAuthCode: string): Promise<void> => {
|
||||
const authClient = DocusignAuthClient.create(props)
|
||||
const tokenResp = await authClient.getAccessTokenWithCode(oAuthCode)
|
||||
if (!tokenResp.success) throw tokenResp.error
|
||||
|
||||
const userInfoResp = await authClient.getUserInfo(tokenResp.data.accessToken, tokenResp.data.tokenType)
|
||||
if (!userInfoResp.success) throw userInfoResp.error
|
||||
|
||||
await applyOAuthState(props, tokenResp.data)
|
||||
|
||||
await props.client.configureIntegration({
|
||||
identifier: userInfoResp.data.sub,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
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 GetAccessTokenResp,
|
||||
docusignOAuthAccessTokenRespSchema,
|
||||
type GetUserInfoResp,
|
||||
getUserInfoRespSchema,
|
||||
} from './schemas'
|
||||
import { GetAccessTokenParams } from './types'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
type OAuthParameters = {
|
||||
oauthBaseUrl: string
|
||||
clientId: string
|
||||
clientSecret: string
|
||||
}
|
||||
|
||||
export class DocusignAuthClient {
|
||||
private _axiosClient: AxiosInstance
|
||||
|
||||
private constructor(params: OAuthParameters) {
|
||||
const { oauthBaseUrl, clientId, clientSecret } = params
|
||||
|
||||
// Opted for axios here since the docusign package only has
|
||||
// a function for getting an accessToken from the oauth code
|
||||
// but not for refresh tokens
|
||||
this._axiosClient = axios.create({
|
||||
baseURL: oauthBaseUrl,
|
||||
headers: {
|
||||
Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`,
|
||||
'Cache-Control': 'no-store',
|
||||
Pragma: 'no-cache',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
private async _getAccessToken(params: GetAccessTokenParams): Promise<Result<GetAccessTokenResp>> {
|
||||
// The Docusign API Postman collection example uses FormData for this endpoint.
|
||||
const formData = new FormData()
|
||||
Object.entries(params).forEach(([key, value]) => formData.append(key, value))
|
||||
|
||||
// Docusign doesn't return a timestamp when a token is issued. So a timestamp
|
||||
// is generated prior to the request being made so the expiry time is accurate.
|
||||
const tokenRequestedAt = Date.now()
|
||||
|
||||
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 = docusignOAuthAccessTokenRespSchema.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: {
|
||||
accessToken: result.data.access_token,
|
||||
tokenType: result.data.token_type,
|
||||
expiresAt: tokenRequestedAt + result.data.expires_in * 1000,
|
||||
refreshToken: result.data.refresh_token,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@handleErrors('Failed to obtain Docusign OAuth access token from authorization code')
|
||||
public async getAccessTokenWithCode(code: string): Promise<Result<GetAccessTokenResp>> {
|
||||
return this._getAccessToken({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
})
|
||||
}
|
||||
|
||||
@handleErrors('Failed to refresh Docusign OAuth access token')
|
||||
public async getAccessTokenWithRefreshToken(refreshToken: string): Promise<Result<GetAccessTokenResp>> {
|
||||
return this._getAccessToken({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
})
|
||||
}
|
||||
|
||||
@handleErrors('Failed to retrieve Docusign user info')
|
||||
public async getUserInfo(accessToken: string, tokenType: string): Promise<Result<GetUserInfoResp>> {
|
||||
const resp = await this._axiosClient.get('/oauth/userinfo', {
|
||||
headers: {
|
||||
Authorization: `${tokenType} ${accessToken}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (resp.status < 200 || resp.status >= 300) {
|
||||
return {
|
||||
success: false,
|
||||
error: new RuntimeError(`Failed to retrieve user info | Invalid Status '${resp.status}'`),
|
||||
}
|
||||
}
|
||||
|
||||
const result = getUserInfoRespSchema.safeParse(resp.data)
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: new RuntimeError('Failed to retrieve user info | Schema Parse Failure'),
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, data: result.data }
|
||||
}
|
||||
|
||||
public static create(props: CommonHandlerProps): DocusignAuthClient {
|
||||
const { ctx } = props
|
||||
switch (ctx.configurationType) {
|
||||
case 'sandbox':
|
||||
return new DocusignAuthClient({
|
||||
oauthBaseUrl: bp.secrets.SANDBOX_OAUTH_BASE_URL,
|
||||
clientId: bp.secrets.SANDBOX_CLIENT_ID,
|
||||
clientSecret: bp.secrets.SANDBOX_CLIENT_SECRET,
|
||||
})
|
||||
case null:
|
||||
return new DocusignAuthClient({
|
||||
oauthBaseUrl: bp.secrets.OAUTH_BASE_URL,
|
||||
clientId: bp.secrets.CLIENT_ID,
|
||||
clientSecret: bp.secrets.CLIENT_SECRET,
|
||||
})
|
||||
default:
|
||||
ctx satisfies never
|
||||
}
|
||||
|
||||
throw new RuntimeError(`Unsupported configuration type: ${props.ctx.configurationType}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { TemplateRecipient, SendEnvelopeInput } from 'definitions/actions'
|
||||
import docusign from 'docusign-esign'
|
||||
import { CONVERSATION_ID_FIELD_KEY } from '../config'
|
||||
|
||||
const _createTemplateRecipient = (recipientInfo: TemplateRecipient): docusign.TemplateRole => {
|
||||
const { email, name, role, accessCode } = recipientInfo
|
||||
return {
|
||||
email,
|
||||
name,
|
||||
roleName: role,
|
||||
accessCode,
|
||||
}
|
||||
}
|
||||
|
||||
export const sendEnvelopeInputToEnvelopeDefinition = (input: SendEnvelopeInput): docusign.EnvelopeDefinition => {
|
||||
const { emailSubject, templateId, recipients, conversationId } = input
|
||||
|
||||
return {
|
||||
emailSubject,
|
||||
templateId,
|
||||
templateRoles: recipients.map(_createTemplateRecipient),
|
||||
status: 'sent',
|
||||
customFields: {
|
||||
textCustomFields: [
|
||||
{
|
||||
name: CONVERSATION_ID_FIELD_KEY,
|
||||
value: conversationId,
|
||||
show: 'false',
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { RuntimeError } from '@botpress/sdk'
|
||||
import axios, { AxiosInstance } from 'axios'
|
||||
import docusign from 'docusign-esign'
|
||||
import type { CommonHandlerProps } from '../types'
|
||||
import { getAccountState, getOAuthState } from './auth-utils'
|
||||
import { constructWebhookBody, refreshWebhooks } from './utils'
|
||||
|
||||
type DocusignClientParams = {
|
||||
accountId: string
|
||||
baseUri: string
|
||||
accessToken: string
|
||||
tokenType: string
|
||||
}
|
||||
|
||||
export class DocusignClient {
|
||||
private _axiosClient: AxiosInstance
|
||||
private _accountId: string
|
||||
|
||||
private constructor(params: DocusignClientParams) {
|
||||
const { baseUri, tokenType, accessToken, accountId } = params
|
||||
this._accountId = accountId
|
||||
|
||||
this._axiosClient = axios.create({
|
||||
baseURL: `${baseUri}/restapi/v2.1`,
|
||||
headers: {
|
||||
Authorization: `${tokenType} ${accessToken}`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
public async sendEnvelope(envelope: docusign.EnvelopeDefinition) {
|
||||
try {
|
||||
const resp = await this._axiosClient.post<docusign.EnvelopeSummary>(
|
||||
`/accounts/${this._accountId}/envelopes`,
|
||||
envelope
|
||||
)
|
||||
const { data } = resp
|
||||
|
||||
if (!data.envelopeId) {
|
||||
const message = data.errorDetails ? data.errorDetails.message : 'Did not receive EnvelopeID from Docusign'
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
return {
|
||||
envelopeId: data.envelopeId,
|
||||
}
|
||||
} catch (thrown: unknown) {
|
||||
const err = thrown instanceof Error ? thrown : new Error(String(thrown))
|
||||
throw new RuntimeError('Failed to send envelope', err)
|
||||
}
|
||||
}
|
||||
|
||||
public async listWebhooks(): Promise<docusign.ConnectConfigResults['configurations']> {
|
||||
const resp = await this._axiosClient.get<docusign.ConnectConfigResults>(`/accounts/${this._accountId}/connect`)
|
||||
return resp.data.configurations
|
||||
}
|
||||
|
||||
public async createWebhook(webhookUrl: string, botId: string): Promise<string> {
|
||||
try {
|
||||
const body = constructWebhookBody(webhookUrl, botId)
|
||||
const resp = await this._axiosClient.post<docusign.ConnectCustomConfiguration>(
|
||||
`/accounts/${this._accountId}/connect`,
|
||||
body
|
||||
)
|
||||
|
||||
const { connectId } = resp.data
|
||||
if (!connectId) {
|
||||
throw new RuntimeError('Failed to create webhook due to unexpected api response')
|
||||
}
|
||||
return connectId
|
||||
} catch (thrown: unknown) {
|
||||
if (thrown instanceof RuntimeError) {
|
||||
throw thrown
|
||||
}
|
||||
|
||||
const error =
|
||||
thrown instanceof Error ? new RuntimeError(thrown.message, thrown) : new RuntimeError(String(thrown))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
public async removeWebhook(connectId: string): Promise<boolean> {
|
||||
try {
|
||||
await this._axiosClient.delete(`/accounts/${this._accountId}/connect/${connectId}`)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Creates a docusign api client from the oauth parameters */
|
||||
public static async create(props: CommonHandlerProps): Promise<DocusignClient> {
|
||||
const [oauthState, accountState] = await Promise.all([getOAuthState(props), getAccountState(props)])
|
||||
const { account, hasChanged: hasAccountChanged } = accountState
|
||||
|
||||
const apiClient = new DocusignClient({
|
||||
accountId: account.id,
|
||||
baseUri: account.baseUri,
|
||||
accessToken: oauthState.accessToken,
|
||||
tokenType: oauthState.tokenType,
|
||||
})
|
||||
|
||||
// This side-effect doesn't really belong here, but I can't put
|
||||
// it anywhere else without it leading to fragile behaviour.
|
||||
if (hasAccountChanged) {
|
||||
await refreshWebhooks(props, process.env.BP_WEBHOOK_URL!, apiClient)
|
||||
}
|
||||
|
||||
return apiClient
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { z } from '@botpress/sdk'
|
||||
|
||||
export const docusignOAuthAccessTokenRespSchema = z.object({
|
||||
access_token: z.string(),
|
||||
token_type: z.string().describe('The authentication header type (e.g. "Bearer")'),
|
||||
expires_in: z.number().describe('Seconds until the token expires'),
|
||||
refresh_token: z.string(),
|
||||
scope: z.string(),
|
||||
})
|
||||
|
||||
export type GetAccessTokenResp = {
|
||||
accessToken: string
|
||||
/** The authentication header type for the access token (e.g. "Bearer") */
|
||||
tokenType: string
|
||||
/** The expiry time of the access token represented as a Unix timestamp (milliseconds) */
|
||||
expiresAt: number
|
||||
refreshToken: string
|
||||
}
|
||||
|
||||
const _userAccountSchema = z
|
||||
.object({
|
||||
account_id: z.string(),
|
||||
account_name: z.string(),
|
||||
is_default: z.boolean(),
|
||||
base_uri: z.string().url(),
|
||||
})
|
||||
.strip()
|
||||
export type UserAccount = z.infer<typeof _userAccountSchema>
|
||||
|
||||
export const getUserInfoRespSchema = z
|
||||
.object({
|
||||
sub: z.string().min(1),
|
||||
accounts: z.array(_userAccountSchema).min(1),
|
||||
})
|
||||
.strip()
|
||||
export type GetUserInfoResp = z.infer<typeof getUserInfoRespSchema>
|
||||
@@ -0,0 +1,9 @@
|
||||
export type GetAccessTokenParams =
|
||||
| {
|
||||
grant_type: 'authorization_code'
|
||||
code: string
|
||||
}
|
||||
| {
|
||||
grant_type: 'refresh_token'
|
||||
refresh_token: string
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { CommonHandlerProps } from '../types'
|
||||
import { DocusignClient } from '.'
|
||||
|
||||
export const constructWebhookBody = (webhookUrl: string, botId: string, additionalProps: object = {}) => {
|
||||
return {
|
||||
configurationType: 'custom',
|
||||
urlToPublishTo: webhookUrl,
|
||||
name: `Botpress Integration | Bot ID: ${botId}`,
|
||||
allowEnvelopePublish: 'true',
|
||||
enableLog: 'true',
|
||||
deliveryMode: 'SIM',
|
||||
requiresAcknowledgement: 'true',
|
||||
signMessageWithX509Certificate: 'true',
|
||||
includeTimeZoneInformation: 'true',
|
||||
includeHMAC: 'true',
|
||||
includeEnvelopeVoidReason: 'false',
|
||||
includeSenderAccountasCustomField: 'true',
|
||||
integratorManaged: 'true',
|
||||
envelopeEvents: ['Sent', 'Delivered', 'Completed', 'Declined', 'Voided'],
|
||||
events: ['envelope-resent', 'envelope-reminder-sent'],
|
||||
allUsers: 'true',
|
||||
eventData: {
|
||||
version: 'restv2.1',
|
||||
includeData: ['custom_fields'],
|
||||
},
|
||||
...additionalProps,
|
||||
}
|
||||
}
|
||||
|
||||
export const cleanupWebhooks = async (props: CommonHandlerProps, webhookUrl: string, apiClient?: DocusignClient) => {
|
||||
apiClient ??= await DocusignClient.create(props)
|
||||
|
||||
const resp = await apiClient.listWebhooks()
|
||||
const webhookDeletionPromises = resp?.reduce((webhookPromises, configuration) => {
|
||||
const { urlToPublishTo, connectId } = configuration
|
||||
if (!connectId || urlToPublishTo !== webhookUrl) {
|
||||
return webhookPromises
|
||||
}
|
||||
|
||||
const promise = apiClient.removeWebhook(connectId)
|
||||
return webhookPromises.concat(promise)
|
||||
}, [] as Promise<boolean>[])
|
||||
|
||||
await Promise.allSettled(webhookDeletionPromises ?? [])
|
||||
}
|
||||
|
||||
export const refreshWebhooks = async (props: CommonHandlerProps, webhookUrl: string, apiClient?: DocusignClient) => {
|
||||
apiClient ??= await DocusignClient.create(props)
|
||||
|
||||
await cleanupWebhooks(props, webhookUrl, apiClient)
|
||||
|
||||
await apiClient.createWebhook(webhookUrl, props.ctx.botId)
|
||||
}
|
||||
Reference in New Issue
Block a user