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,16 @@
import { AbTestBlock, SessionState } from '@typebot.io/schemas'
import { ExecuteLogicResponse } from '../../../types'
export const executeAbTest = (
_: SessionState,
block: AbTestBlock
): ExecuteLogicResponse => {
const aEdgeId = block.items[0].outgoingEdgeId
const random = Math.random() * 100
if (random < block.options.aPercent && aEdgeId) {
return { outgoingEdgeId: aEdgeId }
}
const bEdgeId = block.items[1].outgoingEdgeId
if (bEdgeId) return { outgoingEdgeId: bEdgeId }
return { outgoingEdgeId: block.outgoingEdgeId }
}

View File

@@ -0,0 +1,170 @@
import { isNotDefined, isDefined } from '@typebot.io/lib'
import {
Comparison,
ComparisonOperators,
Condition,
LogicalOperator,
Variable,
} from '@typebot.io/schemas'
import { findUniqueVariableValue } from '../../../variables/findUniqueVariableValue'
import { parseVariables } from '../../../variables/parseVariables'
export const executeCondition =
(variables: Variable[]) =>
(condition: Condition): boolean =>
condition.logicalOperator === LogicalOperator.AND
? condition.comparisons.every(executeComparison(variables))
: condition.comparisons.some(executeComparison(variables))
const executeComparison =
(variables: Variable[]) =>
(comparison: Comparison): boolean => {
if (!comparison?.variableId) return false
const inputValue =
variables.find((v) => v.id === comparison.variableId)?.value ?? null
const value =
comparison.value === 'undefined' || comparison.value === 'null'
? null
: findUniqueVariableValue(variables)(comparison.value) ??
parseVariables(variables)(comparison.value)
if (isNotDefined(comparison.comparisonOperator)) return false
switch (comparison.comparisonOperator) {
case ComparisonOperators.CONTAINS: {
const contains = (a: string | null, b: string | null) => {
if (b === '' || !b || !a) return false
return a
.toLowerCase()
.trim()
.normalize()
.includes(b.toLowerCase().trim().normalize())
}
return compare(contains, inputValue, value, 'some')
}
case ComparisonOperators.NOT_CONTAINS: {
const notContains = (a: string | null, b: string | null) => {
if (b === '' || !b || !a) return true
return !a
.toLowerCase()
.trim()
.normalize()
.includes(b.toLowerCase().trim().normalize())
}
return compare(notContains, inputValue, value)
}
case ComparisonOperators.EQUAL: {
return compare(
(a, b) => {
if (typeof a === 'string' && typeof b === 'string')
return a.normalize() === b.normalize()
return a === b
},
inputValue,
value
)
}
case ComparisonOperators.NOT_EQUAL: {
return compare(
(a, b) => {
if (typeof a === 'string' && typeof b === 'string')
return a.normalize() !== b.normalize()
return a !== b
},
inputValue,
value
)
}
case ComparisonOperators.GREATER: {
if (isNotDefined(inputValue) || isNotDefined(value)) return false
if (typeof inputValue === 'string') {
if (typeof value === 'string')
return parseDateOrNumber(inputValue) > parseDateOrNumber(value)
return Number(inputValue) > value.length
}
if (typeof value === 'string') return inputValue.length > Number(value)
return inputValue.length > value.length
}
case ComparisonOperators.LESS: {
if (isNotDefined(inputValue) || isNotDefined(value)) return false
if (typeof inputValue === 'string') {
if (typeof value === 'string')
return parseDateOrNumber(inputValue) < parseDateOrNumber(value)
return Number(inputValue) < value.length
}
if (typeof value === 'string') return inputValue.length < Number(value)
return inputValue.length < value.length
}
case ComparisonOperators.IS_SET: {
return isDefined(inputValue) && inputValue.length > 0
}
case ComparisonOperators.IS_EMPTY: {
return isNotDefined(inputValue) || inputValue.length === 0
}
case ComparisonOperators.STARTS_WITH: {
const startsWith = (a: string | null, b: string | null) => {
if (b === '' || !b || !a) return false
return a
.toLowerCase()
.trim()
.normalize()
.startsWith(b.toLowerCase().trim().normalize())
}
return compare(startsWith, inputValue, value)
}
case ComparisonOperators.ENDS_WITH: {
const endsWith = (a: string | null, b: string | null) => {
if (b === '' || !b || !a) return false
return a
.toLowerCase()
.trim()
.normalize()
.endsWith(b.toLowerCase().trim().normalize())
}
return compare(endsWith, inputValue, value)
}
case ComparisonOperators.MATCHES_REGEX: {
const matchesRegex = (a: string | null, b: string | null) => {
if (b === '' || !b || !a) return false
return new RegExp(b).test(a)
}
return compare(matchesRegex, inputValue, value, 'some')
}
case ComparisonOperators.NOT_MATCH_REGEX: {
const matchesRegex = (a: string | null, b: string | null) => {
if (b === '' || !b || !a) return false
return !new RegExp(b).test(a)
}
return compare(matchesRegex, inputValue, value)
}
}
}
const compare = (
compareStrings: (a: string | null, b: string | null) => boolean,
a: Exclude<Variable['value'], undefined>,
b: Exclude<Variable['value'], undefined>,
type: 'every' | 'some' = 'every'
): boolean => {
if (!a || typeof a === 'string') {
if (!b || typeof b === 'string') return compareStrings(a, b)
return type === 'every'
? b.every((b) => compareStrings(a, b))
: b.some((b) => compareStrings(a, b))
}
if (!b || typeof b === 'string') {
return type === 'every'
? a.every((a) => compareStrings(a, b))
: a.some((a) => compareStrings(a, b))
}
if (type === 'every')
return a.every((a) => b.every((b) => compareStrings(a, b)))
return a.some((a) => b.some((b) => compareStrings(a, b)))
}
const parseDateOrNumber = (value: string): number => {
const parsed = Number(value)
if (isNaN(parsed)) {
const time = Date.parse(value)
return time
}
return parsed
}

View File

@@ -0,0 +1,18 @@
import { ConditionBlock, SessionState } from '@typebot.io/schemas'
import { ExecuteLogicResponse } from '../../../types'
import { executeCondition } from './executeCondition'
export const executeConditionBlock = (
state: SessionState,
block: ConditionBlock
): ExecuteLogicResponse => {
const { variables } = state.typebotsQueue[0].typebot
const passedCondition = block.items.find((item) =>
executeCondition(variables)(item.content)
)
return {
outgoingEdgeId: passedCondition
? passedCondition.outgoingEdgeId
: block.outgoingEdgeId,
}
}

View File

@@ -0,0 +1,29 @@
import { addEdgeToTypebot, createPortalEdge } from '../../../addEdgeToTypebot'
import { ExecuteLogicResponse } from '../../../types'
import { TRPCError } from '@trpc/server'
import { SessionState } from '@typebot.io/schemas'
import { JumpBlock } from '@typebot.io/schemas/features/blocks/logic/jump'
export const executeJumpBlock = (
state: SessionState,
{ groupId, blockId }: JumpBlock['options']
): ExecuteLogicResponse => {
const { typebot } = state.typebotsQueue[0]
const groupToJumpTo = typebot.groups.find((group) => group.id === groupId)
const blockToJumpTo =
groupToJumpTo?.blocks.find((block) => block.id === blockId) ??
groupToJumpTo?.blocks[0]
if (!blockToJumpTo?.groupId)
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Block to jump to is not found',
})
const portalEdge = createPortalEdge({
to: { groupId: blockToJumpTo?.groupId, blockId: blockToJumpTo?.id },
})
const newSessionState = addEdgeToTypebot(state, portalEdge)
return { outgoingEdgeId: portalEdge.id, newSessionState }
}

View File

@@ -0,0 +1,21 @@
import { RedirectBlock, SessionState } from '@typebot.io/schemas'
import { sanitizeUrl } from '@typebot.io/lib'
import { ExecuteLogicResponse } from '../../../types'
import { parseVariables } from '../../../variables/parseVariables'
export const executeRedirect = (
state: SessionState,
block: RedirectBlock
): ExecuteLogicResponse => {
const { variables } = state.typebotsQueue[0].typebot
if (!block.options?.url) return { outgoingEdgeId: block.outgoingEdgeId }
const formattedUrl = sanitizeUrl(parseVariables(variables)(block.options.url))
return {
clientSideActions: [
{
redirect: { url: formattedUrl, isNewTab: block.options.isNewTab },
},
],
outgoingEdgeId: block.outgoingEdgeId,
}
}

View File

@@ -0,0 +1,47 @@
import { ExecuteLogicResponse } from '../../../types'
import { ScriptBlock, SessionState, Variable } from '@typebot.io/schemas'
import { extractVariablesFromText } from '../../../variables/extractVariablesFromText'
import { parseGuessedValueType } from '../../../variables/parseGuessedValueType'
import { parseVariables } from '../../../variables/parseVariables'
export const executeScript = (
state: SessionState,
block: ScriptBlock
): ExecuteLogicResponse => {
const { variables } = state.typebotsQueue[0].typebot
if (!block.options.content || state.whatsApp)
return { outgoingEdgeId: block.outgoingEdgeId }
const scriptToExecute = parseScriptToExecuteClientSideAction(
variables,
block.options.content
)
return {
outgoingEdgeId: block.outgoingEdgeId,
clientSideActions: [
{
scriptToExecute: scriptToExecute,
},
],
}
}
export const parseScriptToExecuteClientSideAction = (
variables: Variable[],
contentToEvaluate: string
) => {
const content = parseVariables(variables, { fieldToParse: 'id' })(
contentToEvaluate
)
const args = extractVariablesFromText(variables)(contentToEvaluate).map(
(variable) => ({
id: variable.id,
value: parseGuessedValueType(variable.value),
})
)
return {
content,
args,
}
}

View File

@@ -0,0 +1,120 @@
import { SessionState, SetVariableBlock, Variable } from '@typebot.io/schemas'
import { byId } from '@typebot.io/lib'
import { ExecuteLogicResponse } from '../../../types'
import { parseScriptToExecuteClientSideAction } from '../script/executeScript'
import { parseGuessedValueType } from '../../../variables/parseGuessedValueType'
import { parseVariables } from '../../../variables/parseVariables'
import { updateVariablesInSession } from '../../../variables/updateVariablesInSession'
export const executeSetVariable = (
state: SessionState,
block: SetVariableBlock
): ExecuteLogicResponse => {
const { variables } = state.typebotsQueue[0].typebot
if (!block.options?.variableId)
return {
outgoingEdgeId: block.outgoingEdgeId,
}
const expressionToEvaluate = getExpressionToEvaluate(state)(block.options)
const isCustomValue = !block.options.type || block.options.type === 'Custom'
if (
expressionToEvaluate &&
!state.whatsApp &&
((isCustomValue && block.options.isExecutedOnClient) ||
block.options.type === 'Moment of the day')
) {
const scriptToExecute = parseScriptToExecuteClientSideAction(
variables,
expressionToEvaluate
)
return {
outgoingEdgeId: block.outgoingEdgeId,
clientSideActions: [
{
setVariable: {
scriptToExecute,
},
expectsDedicatedReply: true,
},
],
}
}
const evaluatedExpression = expressionToEvaluate
? evaluateSetVariableExpression(variables)(expressionToEvaluate)
: undefined
const existingVariable = variables.find(byId(block.options.variableId))
if (!existingVariable) return { outgoingEdgeId: block.outgoingEdgeId }
const newVariable = {
...existingVariable,
value: evaluatedExpression,
}
const newSessionState = updateVariablesInSession(state)([newVariable])
return {
outgoingEdgeId: block.outgoingEdgeId,
newSessionState,
}
}
const evaluateSetVariableExpression =
(variables: Variable[]) =>
(str: string): unknown => {
const isSingleVariable =
str.startsWith('{{') && str.endsWith('}}') && str.split('{{').length === 2
if (isSingleVariable) return parseVariables(variables)(str)
const evaluating = parseVariables(variables, { fieldToParse: 'id' })(
str.includes('return ') ? str : `return ${str}`
)
try {
const func = Function(...variables.map((v) => v.id), evaluating)
return func(...variables.map((v) => parseGuessedValueType(v.value)))
} catch (err) {
return parseVariables(variables)(str)
}
}
const getExpressionToEvaluate =
(state: SessionState) =>
(options: SetVariableBlock['options']): string | null => {
switch (options.type) {
case 'Contact name':
return state.whatsApp?.contact.name ?? ''
case 'Phone number':
return `"${state.whatsApp?.contact.phoneNumber}"` ?? ''
case 'Now':
case 'Today':
return 'new Date().toISOString()'
case 'Tomorrow': {
return 'new Date(Date.now() + 86400000).toISOString()'
}
case 'Yesterday': {
return 'new Date(Date.now() - 86400000).toISOString()'
}
case 'Random ID': {
return 'Math.random().toString(36).substring(2, 15)'
}
case 'User ID': {
return (
state.typebotsQueue[0].resultId ??
'Math.random().toString(36).substring(2, 15)'
)
}
case 'Map item with same index': {
return `const itemIndex = ${options.mapListItemParams?.baseListVariableId}.indexOf(${options.mapListItemParams?.baseItemVariableId})
return ${options.mapListItemParams?.targetListVariableId}.at(itemIndex)`
}
case 'Empty': {
return null
}
case 'Moment of the day': {
return `const now = new Date()
if(now.getHours() < 12) return 'morning'
if(now.getHours() >= 12 && now.getHours() < 18) return 'afternoon'
if(now.getHours() >= 18) return 'evening'
if(now.getHours() >= 22 || now.getHours() < 6) return 'night'`
}
case 'Custom':
case undefined: {
return options.expressionToEvaluate ?? null
}
}
}

View File

@@ -0,0 +1,218 @@
import { addEdgeToTypebot, createPortalEdge } from '../../../addEdgeToTypebot'
import {
TypebotLinkBlock,
SessionState,
Variable,
ReplyLog,
Edge,
typebotInSessionStateSchema,
TypebotInSession,
} from '@typebot.io/schemas'
import { ExecuteLogicResponse } from '../../../types'
import { createId } from '@paralleldrive/cuid2'
import { isNotDefined } from '@typebot.io/lib/utils'
import { createResultIfNotExist } from '../../../queries/createResultIfNotExist'
import { executeJumpBlock } from '../jump/executeJumpBlock'
import prisma from '@typebot.io/lib/prisma'
export const executeTypebotLink = async (
state: SessionState,
block: TypebotLinkBlock
): Promise<ExecuteLogicResponse> => {
const logs: ReplyLog[] = []
const typebotId = block.options.typebotId
if (
typebotId === 'current' ||
typebotId === state.typebotsQueue[0].typebot.id
) {
return executeJumpBlock(state, {
groupId: block.options.groupId,
})
}
if (!typebotId) {
logs.push({
status: 'error',
description: `Failed to link typebot`,
details: `Typebot ID is not specified`,
})
return { outgoingEdgeId: block.outgoingEdgeId, logs }
}
const linkedTypebot = await fetchTypebot(state, typebotId)
if (!linkedTypebot) {
logs.push({
status: 'error',
description: `Failed to link typebot`,
details: `Typebot with ID ${block.options.typebotId} not found`,
})
return { outgoingEdgeId: block.outgoingEdgeId, logs }
}
let newSessionState = await addLinkedTypebotToState(
state,
block,
linkedTypebot
)
const nextGroupId =
block.options.groupId ??
linkedTypebot.groups.find((group) =>
group.blocks.some((block) => block.type === 'start')
)?.id
if (!nextGroupId) {
logs.push({
status: 'error',
description: `Failed to link typebot`,
details: `Group with ID "${block.options.groupId}" not found`,
})
return { outgoingEdgeId: block.outgoingEdgeId, logs }
}
const portalEdge = createPortalEdge({ to: { groupId: nextGroupId } })
newSessionState = addEdgeToTypebot(newSessionState, portalEdge)
return {
outgoingEdgeId: portalEdge.id,
newSessionState,
}
}
const addLinkedTypebotToState = async (
state: SessionState,
block: TypebotLinkBlock,
linkedTypebot: TypebotInSession
): Promise<SessionState> => {
const currentTypebotInQueue = state.typebotsQueue[0]
const isPreview = isNotDefined(currentTypebotInQueue.resultId)
const resumeEdge = createResumeEdgeIfNecessary(state, block)
const currentTypebotWithResumeEdge = resumeEdge
? {
...currentTypebotInQueue,
typebot: {
...currentTypebotInQueue.typebot,
edges: [...currentTypebotInQueue.typebot.edges, resumeEdge],
},
}
: currentTypebotInQueue
const shouldMergeResults = block.options.mergeResults !== false
if (
currentTypebotInQueue.resultId &&
currentTypebotInQueue.answers.length === 0 &&
shouldMergeResults
) {
await createResultIfNotExist({
resultId: currentTypebotInQueue.resultId,
typebot: currentTypebotInQueue.typebot,
hasStarted: false,
isCompleted: false,
})
}
return {
...state,
typebotsQueue: [
{
typebot: {
...linkedTypebot,
variables: fillVariablesWithExistingValues(
linkedTypebot.variables,
currentTypebotInQueue.typebot.variables
),
},
resultId: isPreview
? undefined
: shouldMergeResults
? currentTypebotInQueue.resultId
: createId(),
edgeIdToTriggerWhenDone: block.outgoingEdgeId ?? resumeEdge?.id,
answers: shouldMergeResults ? currentTypebotInQueue.answers : [],
isMergingWithParent: shouldMergeResults,
},
currentTypebotWithResumeEdge,
...state.typebotsQueue.slice(1),
],
}
}
const createResumeEdgeIfNecessary = (
state: SessionState,
block: TypebotLinkBlock
): Edge | undefined => {
const currentTypebotInQueue = state.typebotsQueue[0]
const blockId = block.id
if (block.outgoingEdgeId) return
const currentGroup = currentTypebotInQueue.typebot.groups.find((group) =>
group.blocks.some((block) => block.id === blockId)
)
if (!currentGroup) return
const currentBlockIndex = currentGroup.blocks.findIndex(
(block) => block.id === blockId
)
const nextBlockInGroup =
currentBlockIndex === -1
? undefined
: currentGroup.blocks[currentBlockIndex + 1]
if (!nextBlockInGroup) return
return {
id: createId(),
from: {
groupId: '',
blockId: '',
},
to: {
groupId: nextBlockInGroup.groupId,
blockId: nextBlockInGroup.id,
},
}
}
const fillVariablesWithExistingValues = (
emptyVariables: Variable[],
existingVariables: Variable[]
): Variable[] =>
emptyVariables.map((emptyVariable) => {
const matchedVariable = existingVariables.find(
(existingVariable) => existingVariable.name === emptyVariable.name
)
return {
...emptyVariable,
value: matchedVariable?.value,
}
})
const fetchTypebot = async (state: SessionState, typebotId: string) => {
const { resultId } = state.typebotsQueue[0]
const isPreview = !resultId
if (isPreview) {
const typebot = await prisma.typebot.findUnique({
where: { id: typebotId },
select: {
version: true,
id: true,
edges: true,
groups: true,
variables: true,
},
})
return typebotInSessionStateSchema.parse(typebot)
}
const typebot = await prisma.publicTypebot.findUnique({
where: { typebotId },
select: {
version: true,
id: true,
edges: true,
groups: true,
variables: true,
},
})
if (!typebot) return null
return typebotInSessionStateSchema.parse({
...typebot,
id: typebotId,
})
}

View File

@@ -0,0 +1,45 @@
import prisma from '@typebot.io/lib/prisma'
import { User } from '@typebot.io/prisma'
type Props = {
isPreview?: boolean
typebotIds: string[]
user?: User
}
export const fetchLinkedTypebots = async ({
user,
isPreview,
typebotIds,
}: Props) => {
if (!user || !isPreview)
return prisma.publicTypebot.findMany({
where: { id: { in: typebotIds } },
})
const linkedTypebots = await prisma.typebot.findMany({
where: { id: { in: typebotIds } },
include: {
collaborators: {
select: {
userId: true,
},
},
workspace: {
select: {
members: {
select: {
userId: true,
},
},
},
},
},
})
return linkedTypebots.filter(
(typebot) =>
typebot.collaborators.some(
(collaborator) => collaborator.userId === user.id
) || typebot.workspace.members.some((member) => member.userId === user.id)
)
}

View File

@@ -0,0 +1,51 @@
import { User } from '@typebot.io/prisma'
import {
LogicBlockType,
PublicTypebot,
Typebot,
TypebotLinkBlock,
} from '@typebot.io/schemas'
import { isDefined } from '@typebot.io/lib'
import { fetchLinkedTypebots } from './fetchLinkedTypebots'
type Props = {
typebots: Pick<PublicTypebot, 'groups'>[]
user?: User
isPreview?: boolean
}
export const getPreviouslyLinkedTypebots =
({ typebots, user, isPreview }: Props) =>
async (
capturedLinkedBots: (Typebot | PublicTypebot)[]
): Promise<(Typebot | PublicTypebot)[]> => {
const linkedTypebotIds = typebots
.flatMap((typebot) =>
(
typebot.groups
.flatMap((group) => group.blocks)
.filter(
(block) =>
block.type === LogicBlockType.TYPEBOT_LINK &&
isDefined(block.options.typebotId) &&
!capturedLinkedBots.some(
(bot) =>
('typebotId' in bot ? bot.typebotId : bot.id) ===
block.options.typebotId
)
) as TypebotLinkBlock[]
).map((s) => s.options.typebotId)
)
.filter(isDefined)
if (linkedTypebotIds.length === 0) return capturedLinkedBots
const linkedTypebots = (await fetchLinkedTypebots({
user,
typebotIds: linkedTypebotIds,
isPreview,
})) as (Typebot | PublicTypebot)[]
return getPreviouslyLinkedTypebots({
typebots: linkedTypebots,
user,
isPreview,
})([...capturedLinkedBots, ...linkedTypebots])
}

View File

@@ -0,0 +1,32 @@
import { ExecuteLogicResponse } from '../../../types'
import { SessionState, WaitBlock } from '@typebot.io/schemas'
import { parseVariables } from '../../../variables/parseVariables'
export const executeWait = (
state: SessionState,
block: WaitBlock
): ExecuteLogicResponse => {
const { variables } = state.typebotsQueue[0].typebot
if (!block.options.secondsToWaitFor)
return { outgoingEdgeId: block.outgoingEdgeId }
const parsedSecondsToWaitFor = safeParseInt(
parseVariables(variables)(block.options.secondsToWaitFor)
)
return {
outgoingEdgeId: block.outgoingEdgeId,
clientSideActions: parsedSecondsToWaitFor
? [
{
wait: { secondsToWaitFor: parsedSecondsToWaitFor },
expectsDedicatedReply: block.options.shouldPause,
},
]
: undefined,
}
}
const safeParseInt = (value: string) => {
const parsedValue = parseInt(value)
return isNaN(parsedValue) ? undefined : parsedValue
}