chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
import { z } from '@botpress/sdk'
|
||||
|
||||
const _templateRecipientSchema = z.object({
|
||||
name: z.string().title('Recipient Name').describe("The recipient's full name"),
|
||||
email: z.string().title('Recipient Email').describe("The recipient's email address"),
|
||||
role: z.string().title('Template Recipient Role').describe('The role keyword defined in the template'),
|
||||
accessCode: z
|
||||
.string()
|
||||
.optional()
|
||||
.title('Access Code')
|
||||
.describe('An access code that is required to access the envelope'),
|
||||
})
|
||||
export type TemplateRecipient = z.infer<typeof _templateRecipientSchema>
|
||||
|
||||
export const sendEnvelopeInputSchema = z.object({
|
||||
templateId: z.string().title('Template ID').describe('The id of the envelope template'),
|
||||
recipients: z
|
||||
.array(_templateRecipientSchema)
|
||||
.min(1)
|
||||
.title('Envelope Recipients')
|
||||
.describe(
|
||||
"The recipients of the envelope to send as defined in the template (Note: adding additional recipients with roles not defined in the template will cause them to default as 'signers')"
|
||||
),
|
||||
emailSubject: z
|
||||
.string()
|
||||
.optional()
|
||||
.title('Email Subject')
|
||||
.describe(
|
||||
'Sets the subject field of the sent envelope email (Leaving this empty will fallback to the template default subject)'
|
||||
),
|
||||
conversationId: z
|
||||
.string()
|
||||
.placeholder('{{ event.conversationId }}')
|
||||
.optional()
|
||||
.title('Conversation ID')
|
||||
.describe('The ID of the conversation'),
|
||||
})
|
||||
export type SendEnvelopeInput = z.infer<typeof sendEnvelopeInputSchema>
|
||||
|
||||
export const sendEnvelopeOutputSchema = z.object({
|
||||
envelopeId: z.string().title('Envelope ID').describe('The id of the sent envelope'),
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
import { z } from '@botpress/sdk'
|
||||
|
||||
export const configurationSchema = z.object({
|
||||
accountId: z
|
||||
.string()
|
||||
.optional()
|
||||
.title('API Account ID (Optional)')
|
||||
.describe(
|
||||
'The docusign user\'s "API Account ID" (This is a GUID that is found in "Apps & Keys")\nThe default account will be selected if left empty'
|
||||
)
|
||||
.placeholder('e.g. a1b2c3d4-e5f6-g7h8-i9j0-d4c3b2a1'),
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
import { z } from '@botpress/sdk'
|
||||
|
||||
export const envelopeEventSchema = z.object({
|
||||
userId: z.string().title('User ID').describe("The Docusign user's ID"),
|
||||
accountId: z
|
||||
.string()
|
||||
.title('API Account ID')
|
||||
.describe('The docusign user\'s "API Account ID" (This is a GUID that is found in "Apps & Keys")'),
|
||||
envelopeId: z.string().title('Envelope ID').describe('The id of the sent envelope'),
|
||||
triggeredAt: z.string().datetime().title('Triggered At').describe('The datetime when the event was triggered'),
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import rootConfig from '../../eslint.config.mjs'
|
||||
|
||||
export default [
|
||||
...rootConfig,
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,43 @@
|
||||
# Integrate Docusign with AI
|
||||
|
||||
Unlock the full potential of Docusign by integrating it with AI technologies. With AI, you can automate document workflows, generate intelligent insights, enhance security measures, and improve user experience. From streamlining contract management with AI-powered analytics to using machine learning for data extraction, the possibilities are endless.
|
||||
|
||||
# What You Can Do with a Docusign AI Integration
|
||||
|
||||
By integrating Docusign with AI-driven tools, you can unlock new possibilities to enhance document processes, security, and user interaction. Here are some key features you can leverage:
|
||||
|
||||
## 1. Automate Document Workflows
|
||||
|
||||
With AI, routine document workflows such as signing, sending reminders, and tracking status can be automated within Docusign, saving time and reducing manual effort.
|
||||
|
||||
## 2. AI-Powered Analytics
|
||||
|
||||
Integrate Docusign with AI analytics tools to monitor document performance, gain insights from contract data, and optimize workflow efficiency for better decision-making.
|
||||
|
||||
## 3. Intelligent Data Extraction
|
||||
|
||||
Leverage machine learning to automatically extract key information from documents, reducing errors and speeding up data processing.
|
||||
|
||||
## 4. Enhanced Security Measures
|
||||
|
||||
By integrating AI-driven security features, you can enhance the protection of sensitive documents, detect fraudulent activities, and ensure compliance with industry standards.
|
||||
|
||||
# Benefits of Integrating Docusign with AI
|
||||
|
||||
By integrating AI into Docusign, your organization can:
|
||||
|
||||
- **Automate document handling:** Use AI to automate the preparation and distribution of documents, allowing your team to focus on more strategic tasks.
|
||||
- **Generate insights:** Leverage AI to analyze document data and generate actionable insights for business strategy and planning.
|
||||
- **Real-time language processing:** Instantly process and translate document content into multiple languages, facilitating global operations.
|
||||
- **Fraud detection:** Utilize AI to identify unusual patterns and prevent fraudulent activities in document transactions.
|
||||
- **Workflow optimization:** Integrate AI-powered workflow management to automatically assign, update, and track document tasks based on context and priority.
|
||||
|
||||
# What is Docusign?
|
||||
|
||||
Docusign is a leading electronic signature and agreement platform that allows businesses to prepare, sign, act on, and manage agreements in a secure digital environment. By integrating AI into Docusign, you can enhance its capabilities with advanced automation, security, and data-driven insights. Related Integrations:
|
||||
|
||||
- Salesforce AI Integration
|
||||
- Gmail AI Integration
|
||||
- Zapier AI Integration
|
||||
- PDF Generator AI Integration
|
||||
- HubSpot AI Integration
|
||||
@@ -0,0 +1,12 @@
|
||||
<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_2_27)">
|
||||
<path d="M92.1718 92.1718V124.867C92.1718 126.601 90.7726 128 89.0383 128H3.13349C1.39923 128 0 126.601 0 124.867V38.942C0 37.2077 1.39923 35.8085 3.13349 35.8085H35.8085V89.0383C35.8085 90.7726 37.2077 92.1718 38.942 92.1718H92.1718Z" fill="#4C00FF"/>
|
||||
<path d="M128 46.0761C128 73.5877 111.978 92.1324 92.1718 92.1915V57.5458C92.1718 56.5407 91.7777 55.5948 91.0682 54.8853L73.095 36.9121C72.3855 36.2026 71.4396 35.8085 70.4345 35.8085H35.8085V3.13349C35.8085 1.39923 37.2077 0 38.942 0H87.4223C110.382 0 128 18.5053 128 46.0761Z" fill="#FF5252"/>
|
||||
<path d="M91.0682 54.8853C91.7777 55.5948 92.1718 56.5407 92.1718 57.5458V92.1915H38.942C37.2077 92.1915 35.8085 90.7923 35.8085 89.0581V35.8085H70.4542C71.4593 35.8085 72.4052 36.2026 73.1147 36.9121L91.0682 54.8853Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_2_27">
|
||||
<rect width="128" height="128" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,141 @@
|
||||
import { IntegrationDefinition, z } from '@botpress/sdk'
|
||||
import { sendEnvelopeInputSchema, sendEnvelopeOutputSchema } from 'definitions/actions'
|
||||
import { configurationSchema } from 'definitions/configuration'
|
||||
import { envelopeEventSchema } from 'definitions/events'
|
||||
|
||||
export default new IntegrationDefinition({
|
||||
name: 'docusign',
|
||||
title: 'Docusign',
|
||||
version: '2.1.3',
|
||||
readme: 'hub.md',
|
||||
icon: 'icon.svg',
|
||||
description:
|
||||
'Automate document workflows, generate intelligent insights, enhance security measures, and improve user experience.',
|
||||
configuration: {
|
||||
identifier: {
|
||||
linkTemplateScript: 'linkTemplate.vrl',
|
||||
},
|
||||
schema: configurationSchema,
|
||||
},
|
||||
configurations: {
|
||||
sandbox: {
|
||||
title: 'Sandbox',
|
||||
description: 'Use Docusign developer sandbox environment',
|
||||
identifier: {
|
||||
linkTemplateScript: 'linkTemplateSandbox.vrl',
|
||||
},
|
||||
schema: configurationSchema,
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
sendEnvelope: {
|
||||
title: 'Send Envelope',
|
||||
description: 'Sends an envelope (document) to a recipient to sign it',
|
||||
input: {
|
||||
schema: sendEnvelopeInputSchema,
|
||||
},
|
||||
output: {
|
||||
schema: sendEnvelopeOutputSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
events: {
|
||||
envelopeSent: {
|
||||
title: 'Envelope Sent',
|
||||
description: 'An event that triggers when an envelope is sent to the recipient(s) to be signed',
|
||||
schema: envelopeEventSchema,
|
||||
},
|
||||
envelopeResent: {
|
||||
title: 'Envelope Resent',
|
||||
description: 'An event that triggers when an envelope is resent to the recipient(s) via the dashboard',
|
||||
schema: envelopeEventSchema,
|
||||
},
|
||||
envelopeCompleted: {
|
||||
title: 'Envelope Completed',
|
||||
description: 'An event that triggers when an envelope has been completed/signed by all recipient(s)',
|
||||
schema: envelopeEventSchema,
|
||||
},
|
||||
envelopeDeclined: {
|
||||
title: 'Envelope Declined',
|
||||
description: 'An event that triggers when a recipient has declined to sign an envelope',
|
||||
schema: envelopeEventSchema,
|
||||
},
|
||||
envelopeVoided: {
|
||||
title: 'Envelope Voided',
|
||||
description: 'An event that triggers when an envelope has been voided by the sender',
|
||||
schema: envelopeEventSchema,
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
OAUTH_BASE_URL: {
|
||||
description: 'The base URL used for OAuth authentication',
|
||||
},
|
||||
CLIENT_ID: {
|
||||
description: "The unique identifier that's used to initiate the OAuth flow",
|
||||
},
|
||||
CLIENT_SECRET: {
|
||||
description: "A secret that's used to establish and refresh the OAuth authentication",
|
||||
},
|
||||
SANDBOX_OAUTH_BASE_URL: {
|
||||
description: 'The base URL used for OAuth authentication',
|
||||
},
|
||||
SANDBOX_CLIENT_ID: {
|
||||
description: "The unique identifier that's used to initiate the OAuth flow",
|
||||
},
|
||||
SANDBOX_CLIENT_SECRET: {
|
||||
description: "A secret that's used to establish and refresh the OAuth authentication",
|
||||
},
|
||||
WEBHOOK_SIGNING_SECRET: {
|
||||
description: "The signing key used to validate Docusign's 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'),
|
||||
tokenType: z
|
||||
.string()
|
||||
.describe('The authentication header type for the access token (e.g. "Bearer")')
|
||||
.title('Token Type'),
|
||||
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 Docusign API and refreshing the access token')
|
||||
.title('OAuth Parameters')
|
||||
.nullable(),
|
||||
}),
|
||||
},
|
||||
account: {
|
||||
type: 'integration',
|
||||
schema: z.object({
|
||||
account: z
|
||||
.object({
|
||||
id: z.string().title('API Account ID').describe("The docusign user's api account id"),
|
||||
baseUri: z.string().describe('The base URI for the Docusign API').title('Base URI'),
|
||||
refreshAt: z
|
||||
.number()
|
||||
.min(0)
|
||||
.title('Refresh At')
|
||||
.describe(
|
||||
'The unix timestamp (milliseconds) that the selected account will be refreshed (Only when not explicitly selected in the config)'
|
||||
)
|
||||
.nullable(),
|
||||
})
|
||||
.title('Account Info')
|
||||
.describe("The docusign account's info")
|
||||
.nullable(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
attributes: {
|
||||
category: 'Business Operations',
|
||||
repo: 'botpress',
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
webhookId = to_string!(.webhookId)
|
||||
webhookUrl = to_string!(.webhookUrl)
|
||||
env = to_string!(.env)
|
||||
|
||||
baseDomain = "https://account-d.docusign.com/oauth/auth"
|
||||
clientId = "785488f7-c2a8-4b9f-b9ca-6a94b3dd317c"
|
||||
|
||||
if env == "production" {
|
||||
baseDomain = "https://account.docusign.com/oauth/auth"
|
||||
clientId = "c32faf9a-a4c7-43dc-9b3a-3e1e902c40ec"
|
||||
}
|
||||
|
||||
"{{baseDomain}}?client_id={{ clientId }}&response_type=code&scope=signature&state={{ webhookId }}&redirect_uri={{ webhookUrl }}/oauth"
|
||||
@@ -0,0 +1,11 @@
|
||||
webhookId = to_string!(.webhookId)
|
||||
webhookUrl = to_string!(.webhookUrl)
|
||||
env = to_string!(.env)
|
||||
|
||||
clientId = "c1b64db1-82eb-4381-9f4e-a355d4742406"
|
||||
|
||||
if env == "production" {
|
||||
clientId = "806a4db1-5ec8-4eb8-a9d0-d188046207ad"
|
||||
}
|
||||
|
||||
"https://account-d.docusign.com/oauth/auth?client_id={{ clientId }}&response_type=code&scope=signature&state={{ webhookId }}&redirect_uri={{ webhookUrl }}/oauth"
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@botpresshub/docusign",
|
||||
"scripts": {
|
||||
"build": "bp add -y && bp build",
|
||||
"check:type": "tsc --noEmit",
|
||||
"check:bplint": "bp lint",
|
||||
"test": "vitest --run"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@botpress/client": "workspace:*",
|
||||
"@botpress/common": "workspace:*",
|
||||
"@botpress/sdk": "workspace:*",
|
||||
"axios": "^1.12.2",
|
||||
"docusign-esign": "^8.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@botpress/cli": "workspace:*",
|
||||
"@botpress/sdk": "workspace:*",
|
||||
"@types/docusign-esign": "^5.19.9"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { sendEnvelope } from './send-envelope'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export default {
|
||||
sendEnvelope,
|
||||
} satisfies bp.IntegrationProps['actions']
|
||||
@@ -0,0 +1,14 @@
|
||||
import { DocusignClient } from '../docusign-api'
|
||||
import { sendEnvelopeInputToEnvelopeDefinition } from '../docusign-api/helpers'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const sendEnvelope: bp.IntegrationProps['actions']['sendEnvelope'] = async ({ input, ...props }) => {
|
||||
const envelopeDef = sendEnvelopeInputToEnvelopeDefinition(input)
|
||||
|
||||
const apiClient = await DocusignClient.create(props)
|
||||
const resp = await apiClient.sendEnvelope(envelopeDef)
|
||||
|
||||
return {
|
||||
envelopeId: resp.envelopeId,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const CONVERSATION_ID_FIELD_KEY = 'Botpress-Conversation-ID'
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { generateRedirection, getInterstitialUrl } from '@botpress/common/src/oauth-wizard/interstitial'
|
||||
import { exchangeAuthCodeForRefreshToken } from './docusign-api/auth-utils'
|
||||
import { dispatchIntegrationEvent } from './webhooks/event-dispatcher'
|
||||
import { parseWebhookEvent, verifyWebhookSignature } from './webhooks/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 = 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,12 @@
|
||||
import { refreshAccountState } from './docusign-api/auth-utils'
|
||||
import { cleanupWebhooks, refreshWebhooks } from './docusign-api/utils'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const register: bp.Integration['register'] = async (props) => {
|
||||
await refreshAccountState(props)
|
||||
await refreshWebhooks(props, props.webhookUrl)
|
||||
}
|
||||
|
||||
export const unregister: bp.Integration['unregister'] = async (props) => {
|
||||
await cleanupWebhooks(props, props.webhookUrl)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import * as bp from '.botpress'
|
||||
|
||||
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,15 @@
|
||||
import { Result } from './types'
|
||||
|
||||
export const safeParseJson = (json: string): Result<unknown> => {
|
||||
try {
|
||||
return {
|
||||
success: true,
|
||||
data: JSON.parse(json),
|
||||
}
|
||||
} catch (thrown: unknown) {
|
||||
return {
|
||||
success: false,
|
||||
error: thrown instanceof Error ? thrown : new Error(String(thrown)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import * as inviteeHandlers from './event-handlers'
|
||||
import { AllEnvelopeEvents } from './schemas'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const dispatchIntegrationEvent = async (props: bp.HandlerProps, webhookEvent: AllEnvelopeEvents) => {
|
||||
switch (webhookEvent.event) {
|
||||
case 'envelope-sent':
|
||||
return await inviteeHandlers.handleEnvelopeEvent(props, 'envelopeSent', webhookEvent)
|
||||
case 'envelope-resent':
|
||||
return await inviteeHandlers.handleEnvelopeEvent(props, 'envelopeResent', webhookEvent)
|
||||
case 'envelope-completed':
|
||||
return await inviteeHandlers.handleEnvelopeEvent(props, 'envelopeCompleted', webhookEvent)
|
||||
case 'envelope-declined':
|
||||
return await inviteeHandlers.handleEnvelopeEvent(props, 'envelopeDeclined', webhookEvent)
|
||||
case 'envelope-voided':
|
||||
return await inviteeHandlers.handleEnvelopeEvent(props, 'envelopeVoided', webhookEvent)
|
||||
default:
|
||||
props.logger.warn(`Ignoring unsupported webhook type: '${webhookEvent.event}'`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { CONVERSATION_ID_FIELD_KEY } from '../config'
|
||||
import { EnvelopeEvent } from './schemas'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const handleEnvelopeEvent = async (
|
||||
props: bp.HandlerProps,
|
||||
eventType: keyof bp.events.Events,
|
||||
event: EnvelopeEvent
|
||||
) => {
|
||||
const { userId, accountId, envelopeId, envelopeSummary } = event.data
|
||||
|
||||
const conversationIdField = envelopeSummary.customFields.textCustomFields.find((customField) => {
|
||||
return customField.name === CONVERSATION_ID_FIELD_KEY
|
||||
})
|
||||
const conversationId = conversationIdField?.value ?? undefined
|
||||
|
||||
return await props.client.createEvent({
|
||||
type: eventType,
|
||||
conversationId,
|
||||
payload: {
|
||||
userId,
|
||||
accountId,
|
||||
envelopeId,
|
||||
triggeredAt: event.generatedDateTime.toISOString(),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { z } from '@botpress/sdk'
|
||||
|
||||
const _ignoredEnvelopeEventsSchema = z
|
||||
.object({
|
||||
// Add more as necessary
|
||||
event: z.literal('envelope-delivered'), // The delivered event is ignored since it's trigger is misleading
|
||||
})
|
||||
.strip()
|
||||
|
||||
const _customTextFieldSchema = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
value: z.string(),
|
||||
})
|
||||
.strip()
|
||||
|
||||
const _envelopeEventSchema = z
|
||||
.object({
|
||||
event: z.union([
|
||||
z.literal('envelope-sent'),
|
||||
z.literal('envelope-resent'),
|
||||
z.literal('envelope-completed'),
|
||||
z.literal('envelope-declined'),
|
||||
z.literal('envelope-voided'),
|
||||
]),
|
||||
generatedDateTime: z.coerce.date(),
|
||||
data: z
|
||||
.object({
|
||||
userId: z.string(),
|
||||
accountId: z.string(),
|
||||
envelopeId: z.string(),
|
||||
envelopeSummary: z
|
||||
.object({
|
||||
customFields: z
|
||||
.object({
|
||||
textCustomFields: z.array(_customTextFieldSchema),
|
||||
})
|
||||
.strip(),
|
||||
})
|
||||
.strip(),
|
||||
})
|
||||
.strip(),
|
||||
})
|
||||
.strip()
|
||||
export type EnvelopeEvent = z.infer<typeof _envelopeEventSchema>
|
||||
|
||||
export const allEnvelopeEventsSchema = z.union([_envelopeEventSchema, _ignoredEnvelopeEventsSchema])
|
||||
export type AllEnvelopeEvents = z.infer<typeof allEnvelopeEventsSchema>
|
||||
@@ -0,0 +1,86 @@
|
||||
import crypto from 'crypto'
|
||||
import { Result } from '../types'
|
||||
import { safeParseJson } from '../utils'
|
||||
import { AllEnvelopeEvents, allEnvelopeEventsSchema } from './schemas'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const parseWebhookEvent = (props: bp.HandlerProps): Result<AllEnvelopeEvents> => {
|
||||
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 Docusign Webhook Payload: ${parseResult.error.message}`),
|
||||
}
|
||||
}
|
||||
|
||||
const zodResult = allEnvelopeEventsSchema.safeParse(parseResult.data)
|
||||
if (!zodResult.success) {
|
||||
const errorMsg = `Webhook handler received unexpected payload: ${zodResult.error.message}`
|
||||
props.logger.error(errorMsg)
|
||||
return { success: false, error: new Error(errorMsg) }
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: zodResult.data,
|
||||
}
|
||||
}
|
||||
|
||||
export const verifyWebhookSignature = (props: bp.HandlerProps): Result<null> => {
|
||||
const { body, headers } = props.req
|
||||
if (!body) {
|
||||
return { success: false, error: new Error('Received empty webhook payload') }
|
||||
}
|
||||
|
||||
const headerResult = _parseSignatureHeader(headers)
|
||||
if (!headerResult.success) {
|
||||
return headerResult
|
||||
}
|
||||
const signatureHash = headerResult.data
|
||||
const signingKey = bp.secrets.WEBHOOK_SIGNING_SECRET!
|
||||
|
||||
try {
|
||||
const computedHash = _computeHash(signingKey, body)
|
||||
const isValid = _isHashValid(signatureHash, computedHash)
|
||||
if (!isValid) {
|
||||
return { success: false, error: new Error('Webhook failed signature verification') }
|
||||
}
|
||||
|
||||
return { success: true, data: null }
|
||||
} catch (thrown: unknown) {
|
||||
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
|
||||
return { success: false, error }
|
||||
}
|
||||
}
|
||||
|
||||
const WEBHOOK_SIGNATURE_HEADER = 'x-docusign-signature-1' as const
|
||||
const _parseSignatureHeader = (headers: Record<string, string | undefined> | undefined): Result<string> => {
|
||||
const signatureHeader = headers?.[WEBHOOK_SIGNATURE_HEADER]
|
||||
|
||||
if (!signatureHeader || signatureHeader.length === 0) {
|
||||
return { success: false, error: new Error('Docusign webhook signature header is missing from the request') }
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: signatureHeader,
|
||||
}
|
||||
}
|
||||
|
||||
const _computeHash = (secret: string, payload: string): string => {
|
||||
const hmac = crypto.createHmac('sha256', secret)
|
||||
hmac.write(payload)
|
||||
hmac.end()
|
||||
return hmac.read().toString('base64')
|
||||
}
|
||||
|
||||
const _isHashValid = (signature: string, payloadHash: string) => {
|
||||
const signatureBuffer = Buffer.from(signature, 'base64')
|
||||
const payloadBuffer = Buffer.from(payloadHash, 'base64')
|
||||
return crypto.timingSafeEqual(signatureBuffer, payloadBuffer)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"paths": { "*": ["./*"] },
|
||||
"outDir": "dist",
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true
|
||||
},
|
||||
"include": [".botpress/**/*", "definitions/**/*", "src/**/*", "*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
import config from '../../vitest.config'
|
||||
export default config
|
||||
Reference in New Issue
Block a user