import { HttpRequestBlock, ZapierBlock, MakeComBlock, PabblyConnectBlock, SessionState, HttpRequest, Variable, HttpResponse, KeyValue, ChatLog, ExecutableHttpRequest, AnswerInSessionState, BotInSession, } from "indite-js/schemas"; import { stringify } from "qs"; import { isDefined, isEmpty, isNotDefined, omit } from "indite-js/lib"; import ky, { HTTPError, Options, TimeoutError } from "ky"; import { resumeWebhookExecution } from "./resumeWebhookExecution"; import { ExecuteIntegrationResponse } from "../../../types"; import { parseVariables } from "indite-js/variables/parseVariables"; import prisma from "indite-js/lib/prisma"; import { HttpMethod, defaultTimeout, defaultWebhookAttributes, maxTimeout, } from "indite-js/schemas/features/blocks/integrations/webhook/constants"; import { env } from "indite-js/env"; import { parseAnswers } from "indite-js/results/parseAnswers"; import { JSONParse } from "indite-js/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, bot: state.botsQueue[0].bot, answers: state.botsQueue[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, bot, answers, }: { webhook: HttpRequest; isCustomBody?: boolean; bot: BotInSession; 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, bot.variables ) as ExecutableHttpRequest["headers"] | undefined; const queryParams = stringify( convertKeyValueTableToObject(webhook.queryParams, bot.variables, true), { indices: false } ); const bodyContent = await getBodyContent({ body: webhook.body, answers, variables: bot.variables, isCustomBody, }); const method = webhook.method ?? defaultWebhookAttributes.method; const { data: body, isJson } = bodyContent && method !== HttpMethod.GET ? safeJsonParse( parseVariables(bot.variables, { isInsideJson: !checkIfBodyIsAVariable(bodyContent), })(bodyContent) ) : { data: undefined, isJson: false }; return { url: parseVariables(bot.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 baseRequest = { url, method, headers: headers ?? {}, ...(basicAuth ?? {}), 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 }; const request = body ? !isFormData && isJson ? { ...baseRequest, json: body } : { ...baseRequest, body } : baseRequest; 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 Bot 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[], concatDuplicateInArray = false ) => { 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; if (object[key] && concatDuplicateInArray) { if (Array.isArray(object[key])) (object[key] as string[]).push(value); else object[key] = [object[key] as string, value]; } else object[key] = value; return object; }, {}); }; // 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; };