(webhook) Add client execution option

This commit is contained in:
Baptiste Arnaud
2023-05-26 09:20:22 +02:00
parent 084a17ffc8
commit 75f9da0a4f
23 changed files with 426 additions and 306 deletions

View File

@@ -71,8 +71,7 @@ if (window.$chatwoot) {
export const executeChatwootBlock = (
{ typebot, result }: SessionState,
block: ChatwootBlock,
lastBubbleBlockId?: string
block: ChatwootBlock
): ExecuteIntegrationResponse => {
const chatwootCode =
block.options.task === 'Close widget'
@@ -88,7 +87,6 @@ export const executeChatwootBlock = (
outgoingEdgeId: block.outgoingEdgeId,
clientSideActions: [
{
lastBubbleBlockId,
chatwoot: {
scriptToExecute: {
content: parseVariables(typebot.variables, { fieldToParse: 'id' })(

View File

@@ -5,8 +5,7 @@ import { GoogleAnalyticsBlock, SessionState } from '@typebot.io/schemas'
export const executeGoogleAnalyticsBlock = (
{ typebot: { variables } }: SessionState,
block: GoogleAnalyticsBlock,
lastBubbleBlockId?: string
block: GoogleAnalyticsBlock
): ExecuteIntegrationResponse => {
const googleAnalytics = deepParseVariables(variables)(block.options)
return {
@@ -19,7 +18,6 @@ export const executeGoogleAnalyticsBlock = (
? Number(googleAnalytics.value)
: undefined,
},
lastBubbleBlockId,
},
],
}

View File

@@ -4,7 +4,6 @@ import {
ZapierBlock,
MakeComBlock,
PabblyConnectBlock,
VariableWithUnknowValue,
SessionState,
Webhook,
Typebot,
@@ -17,17 +16,23 @@ import {
KeyValue,
ReplyLog,
ResultInSession,
ExecutableWebhook,
} from '@typebot.io/schemas'
import { stringify } from 'qs'
import { byId, omit } from '@typebot.io/lib'
import { omit } from '@typebot.io/lib'
import { parseAnswers } from '@typebot.io/lib/results'
import got, { Method, Headers, HTTPError } from 'got'
import got, { Method, HTTPError, OptionsInit } from 'got'
import { parseSampleResult } from './parseSampleResult'
import { ExecuteIntegrationResponse } from '@/features/chat/types'
import { saveErrorLog } from '@/features/logs/saveErrorLog'
import { saveSuccessLog } from '@/features/logs/saveSuccessLog'
import { updateVariables } from '@/features/variables/updateVariables'
import { parseVariables } from '@/features/variables/parseVariables'
import { resumeWebhookExecution } from './resumeWebhookExecution'
type ParsedWebhook = ExecutableWebhook & {
basicAuth: { username?: string; password?: string }
isJson: boolean
}
export const executeWebhookBlock = async (
state: SessionState,
@@ -51,70 +56,34 @@ export const executeWebhookBlock = async (
return { outgoingEdgeId: block.outgoingEdgeId, logs: [log] }
}
const preparedWebhook = prepareWebhookAttributes(webhook, block.options)
const webhookResponse = await executeWebhook({ typebot })(
preparedWebhook,
typebot.variables,
const parsedWebhook = await parseWebhookAttributes(
typebot,
block.groupId,
result
)
const status = webhookResponse.statusCode.toString()
const isError = status.startsWith('4') || status.startsWith('5')
if (isError) {
)(preparedWebhook)
if (!parsedWebhook) {
log = {
status: 'error',
description: `Webhook returned error: ${webhookResponse.data}`,
details: JSON.stringify(webhookResponse.data, null, 2).substring(0, 1000),
description: `Couldn't parse webhook attributes`,
}
result &&
(await saveErrorLog({
resultId: result.id,
message: log.description,
details: log.details,
}))
} else {
log = {
status: 'success',
description: `Webhook executed successfully!`,
details: JSON.stringify(webhookResponse.data, null, 2).substring(0, 1000),
}
result &&
(await saveSuccessLog({
resultId: result.id,
message: log.description,
details: JSON.stringify(webhookResponse.data, null, 2).substring(
0,
1000
),
}))
return { outgoingEdgeId: block.outgoingEdgeId, logs: [log] }
}
const newVariables = block.options.responseVariableMapping.reduce<
VariableWithUnknowValue[]
>((newVariables, varMapping) => {
if (!varMapping?.bodyPath || !varMapping.variableId) return newVariables
const existingVariable = typebot.variables.find(byId(varMapping.variableId))
if (!existingVariable) return newVariables
const func = Function(
'data',
`return data.${parseVariables(typebot.variables)(varMapping?.bodyPath)}`
)
try {
const value: unknown = func(webhookResponse)
return [...newVariables, { ...existingVariable, value }]
} catch (err) {
return newVariables
}
}, [])
if (newVariables.length > 0) {
const newSessionState = await updateVariables(state)(newVariables)
if (block.options.isExecutedOnClient)
return {
outgoingEdgeId: block.outgoingEdgeId,
newSessionState,
clientSideActions: [
{
webhookToExecute: parsedWebhook,
},
],
}
}
return { outgoingEdgeId: block.outgoingEdgeId, logs: log ? [log] : undefined }
const webhookResponse = await executeWebhook(parsedWebhook, result)
return resumeWebhookExecution(state, block)(webhookResponse)
}
const prepareWebhookAttributes = (
@@ -131,19 +100,15 @@ const prepareWebhookAttributes = (
const checkIfBodyIsAVariable = (body: string) => /^{{.+}}$/.test(body)
export const executeWebhook =
({ typebot }: Pick<SessionState, 'typebot'>) =>
async (
webhook: Webhook,
variables: Variable[],
const parseWebhookAttributes =
(
typebot: SessionState['typebot'],
groupId: string,
result: ResultInSession
): Promise<WebhookResponse> => {
if (!webhook.url || !webhook.method)
return {
statusCode: 400,
data: { message: `Webhook doesn't have url or method` },
}
) =>
async (webhook: Webhook): Promise<ParsedWebhook | undefined> => {
if (!webhook.url || !webhook.method) return
const { variables } = typebot
const basicAuth: { username?: string; password?: string } = {}
const basicAuthHeaderIdx = webhook.headers.findIndex(
(h) =>
@@ -161,13 +126,11 @@ export const executeWebhook =
webhook.headers.splice(basicAuthHeaderIdx, 1)
}
const headers = convertKeyValueTableToObject(webhook.headers, variables) as
| Headers
| ExecutableWebhook['headers']
| undefined
const queryParams = stringify(
convertKeyValueTableToObject(webhook.queryParams, variables)
)
const contentType = headers ? headers['Content-Type'] : undefined
const bodyContent = await getBodyContent(
typebot,
[]
@@ -186,62 +149,62 @@ export const executeWebhook =
)
: { data: undefined, isJson: false }
const request = {
return {
url: parseVariables(variables)(
webhook.url + (queryParams !== '' ? `?${queryParams}` : '')
),
method: webhook.method as Method,
basicAuth,
method: webhook.method,
headers,
...basicAuth,
json:
!contentType?.includes('x-www-form-urlencoded') && body && isJson
? body
: undefined,
form:
contentType?.includes('x-www-form-urlencoded') && body
? body
: undefined,
body: body && !isJson ? body : undefined,
body,
isJson,
}
try {
const response = await got(request.url, omit(request, 'url'))
await saveSuccessLog({
resultId: result.id,
message: 'Webhook successfuly executed.',
details: {
statusCode: response.statusCode,
request,
response: safeJsonParse(response.body).data,
},
})
return {
}
export const executeWebhook = async (
webhook: ParsedWebhook,
result: ResultInSession
): Promise<WebhookResponse> => {
const { headers, url, method, basicAuth, body, isJson } = webhook
const contentType = headers ? headers['Content-Type'] : undefined
const request = {
url,
method: method as Method,
headers,
...(basicAuth ?? {}),
json:
!contentType?.includes('x-www-form-urlencoded') && body && isJson
? body
: undefined,
form:
contentType?.includes('x-www-form-urlencoded') && body ? body : undefined,
body: body && !isJson ? (body as string) : undefined,
} satisfies OptionsInit
try {
const response = await got(request.url, omit(request, 'url'))
await saveSuccessLog({
resultId: result.id,
message: 'Webhook successfuly executed.',
details: {
statusCode: response.statusCode,
data: safeJsonParse(response.body).data,
}
} catch (error) {
if (error instanceof HTTPError) {
const response = {
statusCode: error.response.statusCode,
data: safeJsonParse(error.response.body as string).data,
}
await saveErrorLog({
resultId: result.id,
message: 'Webhook returned an error',
details: {
request,
response,
},
})
return response
}
request,
response: safeJsonParse(response.body).data,
},
})
return {
statusCode: response.statusCode,
data: safeJsonParse(response.body).data,
}
} catch (error) {
if (error instanceof HTTPError) {
const response = {
statusCode: 500,
data: { message: `Error from Typebot server: ${error}` },
statusCode: error.response.statusCode,
data: safeJsonParse(error.response.body as string).data,
}
console.error(error)
await saveErrorLog({
resultId: result.id,
message: 'Webhook failed to execute',
message: 'Webhook returned an error',
details: {
request,
response,
@@ -249,7 +212,22 @@ export const executeWebhook =
})
return response
}
const response = {
statusCode: 500,
data: { message: `Error from Typebot server: ${error}` },
}
console.error(error)
await saveErrorLog({
resultId: result.id,
message: 'Webhook failed to execute',
details: {
request,
response,
},
})
return response
}
}
const getBodyContent =
(

View File

@@ -0,0 +1,87 @@
import { ExecuteIntegrationResponse } from '@/features/chat/types'
import { saveErrorLog } from '@/features/logs/saveErrorLog'
import { saveSuccessLog } from '@/features/logs/saveSuccessLog'
import { parseVariables } from '@/features/variables/parseVariables'
import { updateVariables } from '@/features/variables/updateVariables'
import { byId } from '@typebot.io/lib'
import {
MakeComBlock,
PabblyConnectBlock,
VariableWithUnknowValue,
WebhookBlock,
ZapierBlock,
} from '@typebot.io/schemas'
import { ReplyLog, SessionState } from '@typebot.io/schemas/features/chat'
export const resumeWebhookExecution =
(
state: SessionState,
block: WebhookBlock | ZapierBlock | MakeComBlock | PabblyConnectBlock
) =>
async (response: {
statusCode: number
data?: unknown
}): Promise<ExecuteIntegrationResponse> => {
const { typebot, result } = state
let log: ReplyLog | undefined
const status = response.statusCode.toString()
const isError = status.startsWith('4') || status.startsWith('5')
if (isError) {
log = {
status: 'error',
description: `Webhook returned error: ${response.data}`,
details: JSON.stringify(response.data, null, 2).substring(0, 1000),
}
result &&
(await saveErrorLog({
resultId: result.id,
message: log.description,
details: log.details,
}))
} else {
log = {
status: 'success',
description: `Webhook executed successfully!`,
details: JSON.stringify(response.data, null, 2).substring(0, 1000),
}
result &&
(await saveSuccessLog({
resultId: result.id,
message: log.description,
details: JSON.stringify(response.data, null, 2).substring(0, 1000),
}))
}
const newVariables = block.options.responseVariableMapping.reduce<
VariableWithUnknowValue[]
>((newVariables, varMapping) => {
if (!varMapping?.bodyPath || !varMapping.variableId) return newVariables
const existingVariable = typebot.variables.find(
byId(varMapping.variableId)
)
if (!existingVariable) return newVariables
const func = Function(
'data',
`return data.${parseVariables(typebot.variables)(varMapping?.bodyPath)}`
)
try {
const value: unknown = func(response)
return [...newVariables, { ...existingVariable, value }]
} catch (err) {
return newVariables
}
}, [])
if (newVariables.length > 0) {
const newSessionState = await updateVariables(state)(newVariables)
return {
outgoingEdgeId: block.outgoingEdgeId,
newSessionState,
}
}
return {
outgoingEdgeId: block.outgoingEdgeId,
logs: log ? [log] : undefined,
}
}

View File

@@ -5,15 +5,13 @@ import { ExecuteLogicResponse } from '@/features/chat/types'
export const executeRedirect = (
{ typebot: { variables } }: SessionState,
block: RedirectBlock,
lastBubbleBlockId?: string
block: RedirectBlock
): ExecuteLogicResponse => {
if (!block.options?.url) return { outgoingEdgeId: block.outgoingEdgeId }
const formattedUrl = sanitizeUrl(parseVariables(variables)(block.options.url))
return {
clientSideActions: [
{
lastBubbleBlockId,
redirect: { url: formattedUrl, isNewTab: block.options.isNewTab },
},
],

View File

@@ -6,8 +6,7 @@ import { ScriptBlock, SessionState, Variable } from '@typebot.io/schemas'
export const executeScript = (
{ typebot: { variables } }: SessionState,
block: ScriptBlock,
lastBubbleBlockId?: string
block: ScriptBlock
): ExecuteLogicResponse => {
if (!block.options.content) return { outgoingEdgeId: block.outgoingEdgeId }
@@ -21,7 +20,6 @@ export const executeScript = (
clientSideActions: [
{
scriptToExecute: scriptToExecute,
lastBubbleBlockId,
},
],
}

View File

@@ -8,8 +8,7 @@ import { parseScriptToExecuteClientSideAction } from '../script/executeScript'
export const executeSetVariable = async (
state: SessionState,
block: SetVariableBlock,
lastBubbleBlockId?: string
block: SetVariableBlock
): Promise<ExecuteLogicResponse> => {
const { variables } = state.typebot
if (!block.options?.variableId)
@@ -28,7 +27,6 @@ export const executeSetVariable = async (
setVariable: {
scriptToExecute,
},
lastBubbleBlockId,
},
],
}

View File

@@ -4,8 +4,7 @@ import { SessionState, WaitBlock } from '@typebot.io/schemas'
export const executeWait = async (
{ typebot: { variables } }: SessionState,
block: WaitBlock,
lastBubbleBlockId?: string
block: WaitBlock
): Promise<ExecuteLogicResponse> => {
if (!block.options.secondsToWaitFor)
return { outgoingEdgeId: block.outgoingEdgeId }
@@ -19,7 +18,6 @@ export const executeWait = async (
? [
{
wait: { secondsToWaitFor: parsedSecondsToWaitFor },
lastBubbleBlockId,
},
]
: undefined,

View File

@@ -14,6 +14,7 @@ import {
ResultInSession,
SessionState,
SetVariableBlock,
WebhookBlock,
} from '@typebot.io/schemas'
import { isInputBlock, isNotDefined, byId, isDefined } from '@typebot.io/lib'
import { executeGroup } from './executeGroup'
@@ -26,6 +27,7 @@ import { updateVariables } from '@/features/variables/updateVariables'
import { parseVariables } from '@/features/variables/parseVariables'
import { OpenAIBlock } from '@typebot.io/schemas/features/blocks/integrations/openai'
import { resumeChatCompletion } from '@/features/blocks/integrations/openai/resumeChatCompletion'
import { resumeWebhookExecution } from '@/features/blocks/integrations/webhook/resumeWebhookExecution'
export const continueBotFlow =
(state: SessionState) =>
@@ -60,6 +62,12 @@ export const continueBotFlow =
}
newSessionState = await updateVariables(state)([newVariable])
}
} else if (reply && block.type === IntegrationBlockType.WEBHOOK) {
const result = await resumeWebhookExecution(
state,
block
)(JSON.parse(reply))
if (result.newSessionState) newSessionState = result.newSessionState
} else if (
isDefined(reply) &&
block.type === IntegrationBlockType.OPEN_AI &&
@@ -250,7 +258,7 @@ const computeStorageUsed = async (reply: string) => {
const getOutgoingEdgeId =
({ typebot: { variables } }: Pick<SessionState, 'typebot'>) =>
(
block: InputBlock | SetVariableBlock | OpenAIBlock,
block: InputBlock | SetVariableBlock | OpenAIBlock | WebhookBlock,
reply: string | null
) => {
if (

View File

@@ -67,9 +67,9 @@ export const executeGroup =
logs,
}
const executionResponse = isLogicBlock(block)
? await executeLogic(newSessionState, lastBubbleBlockId)(block)
? await executeLogic(newSessionState)(block)
: isIntegrationBlock(block)
? await executeIntegration(newSessionState, lastBubbleBlockId)(block)
? await executeIntegration(newSessionState)(block)
: null
if (!executionResponse) continue
@@ -83,12 +83,17 @@ export const executeGroup =
) {
clientSideActions = [
...(clientSideActions ?? []),
...executionResponse.clientSideActions,
...executionResponse.clientSideActions.map((action) => ({
...action,
lastBubbleBlockId,
})),
]
if (
executionResponse.clientSideActions?.find(
(action) =>
'setVariable' in action || 'streamOpenAiChatCompletion' in action
'setVariable' in action ||
'streamOpenAiChatCompletion' in action ||
'webhookToExecute' in action
)
) {
return {

View File

@@ -12,15 +12,15 @@ import {
import { ExecuteIntegrationResponse } from '../types'
export const executeIntegration =
(state: SessionState, lastBubbleBlockId?: string) =>
(state: SessionState) =>
async (block: IntegrationBlock): Promise<ExecuteIntegrationResponse> => {
switch (block.type) {
case IntegrationBlockType.GOOGLE_SHEETS:
return executeGoogleSheetBlock(state, block)
case IntegrationBlockType.CHATWOOT:
return executeChatwootBlock(state, block, lastBubbleBlockId)
return executeChatwootBlock(state, block)
case IntegrationBlockType.GOOGLE_ANALYTICS:
return executeGoogleAnalyticsBlock(state, block, lastBubbleBlockId)
return executeGoogleAnalyticsBlock(state, block)
case IntegrationBlockType.EMAIL:
return executeSendEmailBlock(state, block)
case IntegrationBlockType.WEBHOOK:

View File

@@ -10,21 +10,21 @@ import { executeTypebotLink } from '@/features/blocks/logic/typebotLink/executeT
import { executeAbTest } from '@/features/blocks/logic/abTest/executeAbTest'
export const executeLogic =
(state: SessionState, lastBubbleBlockId?: string) =>
(state: SessionState) =>
async (block: LogicBlock): Promise<ExecuteLogicResponse> => {
switch (block.type) {
case LogicBlockType.SET_VARIABLE:
return executeSetVariable(state, block, lastBubbleBlockId)
return executeSetVariable(state, block)
case LogicBlockType.CONDITION:
return executeCondition(state, block)
case LogicBlockType.REDIRECT:
return executeRedirect(state, block, lastBubbleBlockId)
return executeRedirect(state, block)
case LogicBlockType.SCRIPT:
return executeScript(state, block, lastBubbleBlockId)
return executeScript(state, block)
case LogicBlockType.TYPEBOT_LINK:
return executeTypebotLink(state, block)
case LogicBlockType.WAIT:
return executeWait(state, block, lastBubbleBlockId)
return executeWait(state, block)
case LogicBlockType.JUMP:
return executeJumpBlock(state, block.options)
case LogicBlockType.AB_TEST: