2
0
Files
bot/packages/bot-engine/blocks/integrations/webhook/executeWebhookBlock.ts
2024-07-11 10:46:00 +02:00

354 lines
9.7 KiB
TypeScript

import {
HttpRequestBlock,
ZapierBlock,
MakeComBlock,
PabblyConnectBlock,
SessionState,
HttpRequest,
Variable,
HttpResponse,
KeyValue,
ChatLog,
ExecutableHttpRequest,
AnswerInSessionState,
TypebotInSession,
} from '@typebot.io/schemas'
import { stringify } from 'qs'
import { isDefined, isEmpty, isNotDefined, omit } from '@typebot.io/lib'
import ky, { HTTPError, Options, TimeoutError } from 'ky'
import { resumeWebhookExecution } from './resumeWebhookExecution'
import { ExecuteIntegrationResponse } from '../../../types'
import { parseVariables } from '@typebot.io/variables/parseVariables'
import prisma from '@typebot.io/lib/prisma'
import {
HttpMethod,
defaultTimeout,
defaultWebhookAttributes,
maxTimeout,
} from '@typebot.io/schemas/features/blocks/integrations/webhook/constants'
import { env } from '@typebot.io/env'
import { parseAnswers } from '@typebot.io/results/parseAnswers'
import { JSONParse } from '@typebot.io/lib/JSONParse'
type ParsedWebhook = ExecutableHttpRequest & {
basicAuth: { username?: string; password?: string }
isJson: boolean
}
export const longReqTimeoutWhitelist = [
'https://api.openai.com',
'https://retune.so',
'https://www.chatbase.co',
'https://channel-connector.orimon.ai',
'https://api.anthropic.com',
]
export const webhookSuccessDescription = `Webhook successfuly executed.`
export const webhookErrorDescription = `Webhook returned an error.`
type Params = { disableRequestTimeout?: boolean; timeout?: number }
export const executeWebhookBlock = async (
state: SessionState,
block: HttpRequestBlock | ZapierBlock | MakeComBlock | PabblyConnectBlock,
params: Params = {}
): Promise<ExecuteIntegrationResponse> => {
const logs: ChatLog[] = []
const webhook =
block.options?.webhook ??
('webhookId' in block
? ((await prisma.webhook.findUnique({
where: { id: block.webhookId },
})) as HttpRequest | null)
: null)
if (!webhook) return { outgoingEdgeId: block.outgoingEdgeId }
const parsedWebhook = await parseWebhookAttributes({
webhook,
isCustomBody: block.options?.isCustomBody,
typebot: state.typebotsQueue[0].typebot,
answers: state.typebotsQueue[0].answers,
})
if (!parsedWebhook) {
logs.push({
status: 'error',
description: `Couldn't parse webhook attributes`,
})
return { outgoingEdgeId: block.outgoingEdgeId, logs }
}
if (block.options?.isExecutedOnClient && !state.whatsApp)
return {
outgoingEdgeId: block.outgoingEdgeId,
clientSideActions: [
{
type: 'webhookToExecute',
webhookToExecute: parsedWebhook,
expectsDedicatedReply: true,
},
],
}
const {
response: webhookResponse,
logs: executeWebhookLogs,
startTimeShouldBeUpdated,
} = await executeWebhook(parsedWebhook, {
...params,
timeout: block.options?.timeout,
})
return {
...resumeWebhookExecution({
state,
block,
logs: executeWebhookLogs,
response: webhookResponse,
}),
startTimeShouldBeUpdated,
}
}
const checkIfBodyIsAVariable = (body: string) => /^{{.+}}$/.test(body)
export const parseWebhookAttributes = async ({
webhook,
isCustomBody,
typebot,
answers,
}: {
webhook: HttpRequest
isCustomBody?: boolean
typebot: TypebotInSession
answers: AnswerInSessionState[]
}): Promise<ParsedWebhook | undefined> => {
if (!webhook.url) return
const basicAuth: { username?: string; password?: string } = {}
const basicAuthHeaderIdx = webhook.headers?.findIndex(
(h) =>
h.key?.toLowerCase() === 'authorization' &&
h.value?.toLowerCase()?.includes('basic')
)
const isUsernamePasswordBasicAuth =
basicAuthHeaderIdx !== -1 &&
isDefined(basicAuthHeaderIdx) &&
webhook.headers?.at(basicAuthHeaderIdx)?.value?.includes(':')
if (isUsernamePasswordBasicAuth) {
const [username, password] =
webhook.headers?.at(basicAuthHeaderIdx)?.value?.slice(6).split(':') ?? []
basicAuth.username = username
basicAuth.password = password
webhook.headers?.splice(basicAuthHeaderIdx, 1)
}
const headers = convertKeyValueTableToObject(
webhook.headers,
typebot.variables
) as ExecutableHttpRequest['headers'] | undefined
const queryParams = stringify(
convertKeyValueTableToObject(webhook.queryParams, typebot.variables)
)
const bodyContent = await getBodyContent({
body: webhook.body,
answers,
variables: typebot.variables,
isCustomBody,
})
const method = webhook.method ?? defaultWebhookAttributes.method
const { data: body, isJson } =
bodyContent && method !== HttpMethod.GET
? safeJsonParse(
parseVariables(typebot.variables, {
isInsideJson: !checkIfBodyIsAVariable(bodyContent),
})(bodyContent)
)
: { data: undefined, isJson: false }
return {
url: parseVariables(typebot.variables)(
webhook.url + (queryParams !== '' ? `?${queryParams}` : '')
),
basicAuth,
method,
headers,
body,
isJson,
}
}
export const executeWebhook = async (
webhook: ParsedWebhook,
params: Params = {}
): Promise<{
response: HttpResponse
logs?: ChatLog[]
startTimeShouldBeUpdated?: boolean
}> => {
const logs: ChatLog[] = []
const { headers, url, method, basicAuth, isJson } = webhook
const contentType = headers ? headers['Content-Type'] : undefined
const isLongRequest = params.disableRequestTimeout
? true
: longReqTimeoutWhitelist.some((whiteListedUrl) =>
url?.includes(whiteListedUrl)
)
const isFormData = contentType?.includes('x-www-form-urlencoded')
let body = webhook.body
if (isFormData && isJson) body = parseFormDataBody(body as object)
const request = {
url,
method,
headers: headers ?? {},
...(basicAuth ?? {}),
json: !isFormData && body && isJson ? body : undefined,
body: body && (isFormData || !isJson) ? body : undefined,
timeout: isNotDefined(env.CHAT_API_TIMEOUT)
? false
: params.timeout && params.timeout !== defaultTimeout
? Math.min(params.timeout, maxTimeout) * 1000
: isLongRequest
? maxTimeout * 1000
: defaultTimeout * 1000,
} satisfies Options & { url: string; body: any }
try {
const response = await ky(request.url, omit(request, 'url'))
const body = response.headers.get('content-type')?.includes('json')
? await response.json()
: await response.text()
logs.push({
status: 'success',
description: webhookSuccessDescription,
details: {
statusCode: response.status,
response: typeof body === 'string' ? safeJsonParse(body).data : body,
request,
},
})
return {
response: {
statusCode: response.status,
data: typeof body === 'string' ? safeJsonParse(body).data : body,
},
logs,
startTimeShouldBeUpdated: true,
}
} catch (error) {
if (error instanceof HTTPError) {
const responseBody = error.response.headers
.get('content-type')
?.includes('json')
? await error.response.json()
: await error.response.text()
const response = {
statusCode: error.response.status,
data:
typeof responseBody === 'string'
? safeJsonParse(responseBody).data
: responseBody,
}
logs.push({
status: 'error',
description: webhookErrorDescription,
details: {
statusCode: error.response.status,
request,
response,
},
})
return { response, logs, startTimeShouldBeUpdated: true }
}
if (error instanceof TimeoutError) {
const response = {
statusCode: 408,
data: {
message: `Request timed out. (${
(request.timeout ? request.timeout : 0) / 1000
}ms)`,
},
}
logs.push({
status: 'error',
description: `Webhook request timed out. (${
(request.timeout ? request.timeout : 0) / 1000
}s)`,
details: {
response,
request,
},
})
return { response, logs, startTimeShouldBeUpdated: true }
}
const response = {
statusCode: 500,
data: { message: `Error from Typebot server: ${error}` },
}
console.error(error)
logs.push({
status: 'error',
description: `Webhook failed to execute.`,
details: {
response,
request,
},
})
return { response, logs, startTimeShouldBeUpdated: true }
}
}
const getBodyContent = async ({
body,
answers,
variables,
isCustomBody,
}: {
body?: string | null
answers: AnswerInSessionState[]
variables: Variable[]
isCustomBody?: boolean
}): Promise<string | undefined> => {
return body === '{{state}}' || isEmpty(body) || isCustomBody !== true
? JSON.stringify(
parseAnswers({
answers,
variables,
})
)
: body ?? undefined
}
export const convertKeyValueTableToObject = (
keyValues: KeyValue[] | undefined,
variables: Variable[]
) => {
if (!keyValues) return
return keyValues.reduce((object, item) => {
const key = parseVariables(variables)(item.key)
const value = parseVariables(variables)(item.value)
if (isEmpty(key) || isEmpty(value)) return object
return {
...object,
[key]: value,
}
}, {})
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const safeJsonParse = (json: unknown): { data: any; isJson: boolean } => {
try {
return { data: JSONParse(json as string), isJson: true }
} catch (err) {
return { data: json, isJson: false }
}
}
const parseFormDataBody = (body: object) => {
const searchParams = new URLSearchParams()
Object.entries(body as object).forEach(([key, value]) => {
searchParams.set(key, value)
})
return searchParams
}