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 => { 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 => { 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: (isFormData && body ? body : undefined) as any, 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 => { 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 }