2
0

♻️ Export bot-engine code into its own package

This commit is contained in:
Baptiste Arnaud
2023-09-20 15:26:52 +02:00
parent 797685aa9d
commit 7d57e8dd06
242 changed files with 645 additions and 639 deletions

View File

@@ -0,0 +1,262 @@
import {
WebhookBlock,
ZapierBlock,
MakeComBlock,
PabblyConnectBlock,
SessionState,
Webhook,
Variable,
WebhookResponse,
WebhookOptions,
defaultWebhookAttributes,
KeyValue,
ReplyLog,
ExecutableWebhook,
AnswerInSessionState,
} from '@typebot.io/schemas'
import { stringify } from 'qs'
import { omit } from '@typebot.io/lib'
import { getDefinedVariables, parseAnswers } from '@typebot.io/lib/results'
import got, { Method, HTTPError, OptionsInit } from 'got'
import { resumeWebhookExecution } from './resumeWebhookExecution'
import { HttpMethod } from '@typebot.io/schemas/features/blocks/integrations/webhook/enums'
import { ExecuteIntegrationResponse } from '../../../types'
import { parseVariables } from '../../../variables/parseVariables'
import prisma from '@typebot.io/lib/prisma'
type ParsedWebhook = ExecutableWebhook & {
basicAuth: { username?: string; password?: string }
isJson: boolean
}
export const executeWebhookBlock = async (
state: SessionState,
block: WebhookBlock | ZapierBlock | MakeComBlock | PabblyConnectBlock
): Promise<ExecuteIntegrationResponse> => {
const logs: ReplyLog[] = []
const webhook =
block.options.webhook ??
((await prisma.webhook.findUnique({
where: { id: block.webhookId },
})) as Webhook | null)
if (!webhook) {
logs.push({
status: 'error',
description: `Couldn't find webhook with id ${block.webhookId}`,
})
return { outgoingEdgeId: block.outgoingEdgeId, logs }
}
const preparedWebhook = prepareWebhookAttributes(webhook, block.options)
const parsedWebhook = await parseWebhookAttributes(
state,
state.typebotsQueue[0].answers
)(preparedWebhook)
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: [
{
webhookToExecute: parsedWebhook,
expectsDedicatedReply: true,
},
],
}
const { response: webhookResponse, logs: executeWebhookLogs } =
await executeWebhook(parsedWebhook)
return resumeWebhookExecution({
state,
block,
logs: executeWebhookLogs,
response: webhookResponse,
})
}
const prepareWebhookAttributes = (
webhook: Webhook,
options: WebhookOptions
): Webhook => {
if (options.isAdvancedConfig === false) {
return { ...webhook, body: '{{state}}', ...defaultWebhookAttributes }
} else if (options.isCustomBody === false) {
return { ...webhook, body: '{{state}}' }
}
return webhook
}
const checkIfBodyIsAVariable = (body: string) => /^{{.+}}$/.test(body)
const parseWebhookAttributes =
(state: SessionState, answers: AnswerInSessionState[]) =>
async (webhook: Webhook): Promise<ParsedWebhook | undefined> => {
if (!webhook.url || !webhook.method) return
const { typebot } = state.typebotsQueue[0]
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 &&
webhook.headers[basicAuthHeaderIdx].value?.includes(':')
if (isUsernamePasswordBasicAuth) {
const [username, password] =
webhook.headers[basicAuthHeaderIdx].value?.slice(6).split(':') ?? []
basicAuth.username = username
basicAuth.password = password
webhook.headers.splice(basicAuthHeaderIdx, 1)
}
const headers = convertKeyValueTableToObject(
webhook.headers,
typebot.variables
) as ExecutableWebhook['headers'] | undefined
const queryParams = stringify(
convertKeyValueTableToObject(webhook.queryParams, typebot.variables)
)
const bodyContent = await getBodyContent({
body: webhook.body,
answers,
variables: typebot.variables,
})
const { data: body, isJson } =
bodyContent && webhook.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: webhook.method,
headers,
body,
isJson,
}
}
export const executeWebhook = async (
webhook: ParsedWebhook
): Promise<{ response: WebhookResponse; logs?: ReplyLog[] }> => {
const logs: ReplyLog[] = []
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'))
logs.push({
status: 'success',
description: `Webhook successfuly executed.`,
details: {
statusCode: response.statusCode,
request,
response: safeJsonParse(response.body).data,
},
})
return {
response: {
statusCode: response.statusCode,
data: safeJsonParse(response.body).data,
},
logs,
}
} catch (error) {
if (error instanceof HTTPError) {
const response = {
statusCode: error.response.statusCode,
data: safeJsonParse(error.response.body as string).data,
}
logs.push({
status: 'error',
description: `Webhook returned an error.`,
details: {
statusCode: error.response.statusCode,
request,
response,
},
})
return { response, logs }
}
const response = {
statusCode: 500,
data: { message: `Error from Typebot server: ${error}` },
}
console.error(error)
logs.push({
status: 'error',
description: `Webhook failed to execute.`,
details: {
request,
response,
},
})
return { response, logs }
}
}
const getBodyContent = async ({
body,
answers,
variables,
}: {
body?: string | null
answers: AnswerInSessionState[]
variables: Variable[]
}): Promise<string | undefined> => {
if (!body) return
return body === '{{state}}'
? JSON.stringify(
parseAnswers({
answers,
variables: getDefinedVariables(variables),
})
)
: body
}
const convertKeyValueTableToObject = (
keyValues: KeyValue[] | undefined,
variables: Variable[]
) => {
if (!keyValues) return
return keyValues.reduce((object, item) => {
if (!item.key) return {}
return {
...object,
[item.key]: parseVariables(variables)(item.value ?? ''),
}
}, {})
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const safeJsonParse = (json: string): { data: any; isJson: boolean } => {
try {
return { data: JSON.parse(json), isJson: true }
} catch (err) {
return { data: json, isJson: false }
}
}

View File

@@ -0,0 +1,215 @@
import {
InputBlock,
InputBlockType,
LogicBlockType,
PublicTypebot,
ResultHeaderCell,
Block,
Typebot,
TypebotLinkBlock,
Variable,
} from '@typebot.io/schemas'
import { isInputBlock, byId, isNotDefined } from '@typebot.io/lib'
import { parseResultHeader } from '@typebot.io/lib/results'
export const parseSampleResult =
(
typebot: Pick<Typebot | PublicTypebot, 'groups' | 'variables' | 'edges'>,
linkedTypebots: (Typebot | PublicTypebot)[]
) =>
async (
currentGroupId: string,
variables: Variable[]
): Promise<Record<string, string | boolean | undefined>> => {
const header = parseResultHeader(typebot, linkedTypebots)
const linkedInputBlocks = await extractLinkedInputBlocks(
typebot,
linkedTypebots
)(currentGroupId)
return {
message: 'This is a sample result, it has been generated ⬇️',
submittedAt: new Date().toISOString(),
...parseResultSample(linkedInputBlocks, header, variables),
}
}
const extractLinkedInputBlocks =
(
typebot: Pick<Typebot | PublicTypebot, 'groups' | 'variables' | 'edges'>,
linkedTypebots: (Typebot | PublicTypebot)[]
) =>
async (
currentGroupId?: string,
direction: 'backward' | 'forward' = 'backward'
): Promise<InputBlock[]> => {
const previousLinkedTypebotBlocks = walkEdgesAndExtract(
'linkedBot',
direction,
typebot
)({
groupId: currentGroupId,
}) as TypebotLinkBlock[]
const linkedBotInputs =
previousLinkedTypebotBlocks.length > 0
? await Promise.all(
previousLinkedTypebotBlocks.map((linkedBot) =>
extractLinkedInputBlocks(
linkedTypebots.find((t) =>
'typebotId' in t
? t.typebotId === linkedBot.options.typebotId
: t.id === linkedBot.options.typebotId
) as Typebot | PublicTypebot,
linkedTypebots
)(linkedBot.options.groupId, 'forward')
)
)
: []
return (
walkEdgesAndExtract(
'input',
direction,
typebot
)({
groupId: currentGroupId,
}) as InputBlock[]
).concat(linkedBotInputs.flatMap((l) => l))
}
const parseResultSample = (
inputBlocks: InputBlock[],
headerCells: ResultHeaderCell[],
variables: Variable[]
) =>
headerCells.reduce<Record<string, string | (string | null)[] | undefined>>(
(resultSample, cell) => {
const inputBlock = inputBlocks.find((inputBlock) =>
cell.blocks?.some((block) => block.id === inputBlock.id)
)
if (isNotDefined(inputBlock)) {
if (cell.variableIds) {
const variableValue = variables.find(
(variable) =>
cell.variableIds?.includes(variable.id) && variable.value
)?.value
return {
...resultSample,
[cell.label]: variableValue ?? 'content',
}
}
return resultSample
}
const variableValue = variables.find(
(variable) => cell.variableIds?.includes(variable.id) && variable.value
)?.value
const value = variableValue ?? getSampleValue(inputBlock)
return {
...resultSample,
[cell.label]: value,
}
},
{}
)
const getSampleValue = (block: InputBlock): string => {
switch (block.type) {
case InputBlockType.CHOICE:
return block.options.isMultipleChoice
? block.items.map((item) => item.content).join(', ')
: block.items[0]?.content ?? 'Item'
case InputBlockType.DATE:
return new Date().toUTCString()
case InputBlockType.EMAIL:
return 'test@email.com'
case InputBlockType.NUMBER:
return '20'
case InputBlockType.PHONE:
return '+33665566773'
case InputBlockType.TEXT:
return 'answer value'
case InputBlockType.URL:
return 'https://test.com'
case InputBlockType.FILE:
return 'https://domain.com/fake-file.png'
case InputBlockType.RATING:
return '8'
case InputBlockType.PAYMENT:
return 'Success'
case InputBlockType.PICTURE_CHOICE:
return block.options.isMultipleChoice
? block.items.map((item) => item.title ?? item.pictureSrc).join(', ')
: block.items[0]?.title ?? block.items[0]?.pictureSrc ?? 'Item'
}
}
const walkEdgesAndExtract =
(
type: 'input' | 'linkedBot',
direction: 'backward' | 'forward',
typebot: Pick<Typebot | PublicTypebot, 'groups' | 'variables' | 'edges'>
) =>
({ groupId }: { groupId?: string }): Block[] => {
const currentGroupId =
groupId ??
(typebot.groups.find((b) => b.blocks[0].type === 'start')?.id as string)
const blocksInGroup = extractBlocksInGroup(
type,
typebot
)({
groupId: currentGroupId,
})
const otherGroupIds = getGroupIds(typebot, direction)(currentGroupId)
return [
...blocksInGroup,
...otherGroupIds.flatMap((groupId) =>
extractBlocksInGroup(type, typebot)({ groupId })
),
]
}
const getGroupIds =
(
typebot: Pick<Typebot | PublicTypebot, 'groups' | 'variables' | 'edges'>,
direction: 'backward' | 'forward',
existingGroupIds?: string[]
) =>
(groupId: string): string[] => {
const groups = typebot.edges.reduce<string[]>((groupIds, edge) => {
if (direction === 'forward')
return (!existingGroupIds ||
!existingGroupIds?.includes(edge.to.groupId)) &&
edge.from.groupId === groupId
? [...groupIds, edge.to.groupId]
: groupIds
return (!existingGroupIds ||
!existingGroupIds.includes(edge.from.groupId)) &&
edge.to.groupId === groupId
? [...groupIds, edge.from.groupId]
: groupIds
}, [])
const newGroups = [...(existingGroupIds ?? []), ...groups]
return groups.concat(
groups.flatMap(getGroupIds(typebot, direction, newGroups))
)
}
const extractBlocksInGroup =
(
type: 'input' | 'linkedBot',
typebot: Pick<Typebot | PublicTypebot, 'groups' | 'variables' | 'edges'>
) =>
({ groupId, blockId }: { groupId: string; blockId?: string }) => {
const currentGroup = typebot.groups.find(byId(groupId))
if (!currentGroup) return []
const blocks: Block[] = []
for (const block of currentGroup.blocks) {
if (block.id === blockId) break
if (type === 'input' && isInputBlock(block)) blocks.push(block)
if (type === 'linkedBot' && block.type === LogicBlockType.TYPEBOT_LINK)
blocks.push(block)
}
return blocks
}

View File

@@ -0,0 +1,82 @@
import { byId } from '@typebot.io/lib'
import {
MakeComBlock,
PabblyConnectBlock,
ReplyLog,
VariableWithUnknowValue,
WebhookBlock,
ZapierBlock,
} from '@typebot.io/schemas'
import { SessionState } from '@typebot.io/schemas/features/chat/sessionState'
import { ExecuteIntegrationResponse } from '../../../types'
import { parseVariables } from '../../../variables/parseVariables'
import { updateVariablesInSession } from '../../../variables/updateVariablesInSession'
type Props = {
state: SessionState
block: WebhookBlock | ZapierBlock | MakeComBlock | PabblyConnectBlock
logs?: ReplyLog[]
response: {
statusCode: number
data?: unknown
}
}
export const resumeWebhookExecution = ({
state,
block,
logs = [],
response,
}: Props): ExecuteIntegrationResponse => {
const { typebot } = state.typebotsQueue[0]
const status = response.statusCode.toString()
const isError = status.startsWith('4') || status.startsWith('5')
const responseFromClient = logs.length === 0
if (responseFromClient)
logs.push(
isError
? {
status: 'error',
description: `Webhook returned error`,
details: response.data,
}
: {
status: 'success',
description: `Webhook executed successfully!`,
details: response.data,
}
)
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 = updateVariablesInSession(state)(newVariables)
return {
outgoingEdgeId: block.outgoingEdgeId,
newSessionState,
logs,
}
}
return {
outgoingEdgeId: block.outgoingEdgeId,
logs,
}
}