2
0

♻️ Introduce typebot v6 with events (#1013)

Closes #885
This commit is contained in:
Baptiste Arnaud
2023-11-08 15:34:16 +01:00
committed by GitHub
parent 68e4fc71fb
commit 35300eaf34
634 changed files with 58971 additions and 31449 deletions

View File

@ -2,7 +2,6 @@ import {
SessionState,
VariableWithValue,
ChoiceInputBlock,
ItemType,
} from '@typebot.io/schemas'
import { isDefined } from '@typebot.io/lib'
import { filterChoiceItems } from './filterChoiceItems'
@ -14,10 +13,10 @@ export const injectVariableValuesInButtonsInputBlock =
(state: SessionState) =>
(block: ChoiceInputBlock): ChoiceInputBlock => {
const { variables } = state.typebotsQueue[0].typebot
if (block.options.dynamicVariableId) {
if (block.options?.dynamicVariableId) {
const variable = variables.find(
(variable) =>
variable.id === block.options.dynamicVariableId &&
variable.id === block.options?.dynamicVariableId &&
isDefined(variable.value)
) as VariableWithValue | undefined
if (!variable) return block
@ -26,7 +25,6 @@ export const injectVariableValuesInButtonsInputBlock =
...block,
items: value.filter(isDefined).map((item, idx) => ({
id: idx.toString(),
type: ItemType.BUTTON,
blockId: block.id,
content: item,
})),

View File

@ -7,7 +7,7 @@ export const parseButtonsReply =
(inputValue: string, block: ChoiceInputBlock): ParsedReply => {
const displayedItems =
injectVariableValuesInButtonsInputBlock(state)(block).items
if (block.options.isMultipleChoice) {
if (block.options?.isMultipleChoice) {
const longestItemsFirst = [...displayedItems].sort(
(a, b) => (b.content?.length ?? 0) - (a.content?.length ?? 0)
)

View File

@ -1,15 +1,11 @@
import { getPrefilledInputValue } from '../../../getPrefilledValue'
import {
DateInputBlock,
DateInputOptions,
SessionState,
Variable,
} from '@typebot.io/schemas'
import { DateInputBlock, SessionState, Variable } from '@typebot.io/schemas'
import { deepParseVariables } from '../../../variables/deepParseVariables'
import { parseVariables } from '../../../variables/parseVariables'
export const parseDateInput =
(state: SessionState) => (block: DateInputBlock) => {
if (!block.options) return block
return {
...block,
options: {
@ -34,8 +30,10 @@ export const parseDateInput =
}
const parseDateLimit = (
limit: DateInputOptions['min'] | DateInputOptions['max'],
hasTime: DateInputOptions['hasTime'],
limit:
| NonNullable<DateInputBlock['options']>['min']
| NonNullable<DateInputBlock['options']>['max'],
hasTime: NonNullable<DateInputBlock['options']>['hasTime'],
variables: Variable[]
) => {
if (!limit) return

View File

@ -1,7 +1,9 @@
import { isDefined } from '@typebot.io/lib'
import { ParsedReply } from '../../../types'
import { DateInputBlock } from '@typebot.io/schemas'
import { parse as chronoParse } from 'chrono-node'
import { format } from 'date-fns'
import { defaultDateInputOptions } from '@typebot.io/schemas/features/blocks/inputs/date/constants'
export const parseDateReply = (
reply: string,
@ -10,8 +12,10 @@ export const parseDateReply = (
const parsedDate = chronoParse(reply)
if (parsedDate.length === 0) return { status: 'fail' }
const formatString =
block.options.format ??
(block.options.hasTime ? 'dd/MM/yyyy HH:mm' : 'dd/MM/yyyy')
block.options?.format ??
(block.options?.hasTime
? defaultDateInputOptions.formatWithTime
: defaultDateInputOptions.format)
const detectedStartDate = parseDateWithNeutralTimezone(
parsedDate[0].start.date()
@ -25,25 +29,27 @@ export const parseDateReply = (
? format(detectedEndDate, formatString)
: undefined
if (block.options.isRange && !endDate) return { status: 'fail' }
if (block.options?.isRange && !endDate) return { status: 'fail' }
const max = block.options?.max
if (
block.options.max &&
(detectedStartDate > new Date(block.options.max) ||
(detectedEndDate && detectedEndDate > new Date(block.options.max)))
isDefined(max) &&
(detectedStartDate > new Date(max) ||
(detectedEndDate && detectedEndDate > new Date(max)))
)
return { status: 'fail' }
const min = block.options?.min
if (
block.options.min &&
(detectedStartDate < new Date(block.options.min) ||
(detectedEndDate && detectedEndDate < new Date(block.options.min)))
isDefined(min) &&
(detectedStartDate < new Date(min) ||
(detectedEndDate && detectedEndDate < new Date(min)))
)
return { status: 'fail' }
return {
status: 'success',
reply: block.options.isRange ? `${startDate} to ${endDate}` : startDate,
reply: block.options?.isRange ? `${startDate} to ${endDate}` : startDate,
}
}

View File

@ -1,6 +1,6 @@
import { TRPCError } from '@trpc/server'
import {
PaymentInputOptions,
PaymentInputBlock,
PaymentInputRuntimeOptions,
SessionState,
StripeCredentials,
@ -9,20 +9,23 @@ import Stripe from 'stripe'
import { decrypt } from '@typebot.io/lib/api/encryption/decrypt'
import { parseVariables } from '../../../variables/parseVariables'
import prisma from '@typebot.io/lib/prisma'
import { defaultPaymentInputOptions } from '@typebot.io/schemas/features/blocks/inputs/payment/constants'
export const computePaymentInputRuntimeOptions =
(state: SessionState) => (options: PaymentInputOptions) =>
(state: SessionState) => (options: PaymentInputBlock['options']) =>
createStripePaymentIntent(state)(options)
const createStripePaymentIntent =
(state: SessionState) =>
async (options: PaymentInputOptions): Promise<PaymentInputRuntimeOptions> => {
async (
options: PaymentInputBlock['options']
): Promise<PaymentInputRuntimeOptions> => {
const {
resultId,
typebot: { variables },
} = state.typebotsQueue[0]
const isPreview = !resultId
if (!options.credentialsId)
if (!options?.credentialsId)
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Missing credentialsId',
@ -39,9 +42,10 @@ const createStripePaymentIntent =
: stripeKeys.live.secretKey,
{ apiVersion: '2022-11-15' }
)
const currency = options?.currency ?? defaultPaymentInputOptions.currency
const amount = Math.round(
Number(parseVariables(variables)(options.amount)) *
(isZeroDecimalCurrency(options.currency) ? 1 : 100)
(isZeroDecimalCurrency(currency) ? 1 : 100)
)
if (isNaN(amount))
throw new TRPCError({
@ -55,7 +59,7 @@ const createStripePaymentIntent =
)
const paymentIntent = await stripe.paymentIntents.create({
amount,
currency: options.currency,
currency,
receipt_email: receiptEmail === '' ? undefined : receiptEmail,
description: options.additionalInformation?.description,
automatic_payment_methods: {
@ -84,7 +88,7 @@ const createStripePaymentIntent =
? stripeKeys.test.publicKey
: stripeKeys.live.publicKey,
amountLabel: priceFormatter.format(
amount / (isZeroDecimalCurrency(options.currency) ? 1 : 100)
amount / (isZeroDecimalCurrency(currency) ? 1 : 100)
),
}
}

View File

@ -1,6 +1,5 @@
import {
VariableWithValue,
ItemType,
PictureChoiceBlock,
Variable,
} from '@typebot.io/schemas'
@ -12,19 +11,19 @@ export const injectVariableValuesInPictureChoiceBlock =
(variables: Variable[]) =>
(block: PictureChoiceBlock): PictureChoiceBlock => {
if (
block.options.dynamicItems?.isEnabled &&
block.options?.dynamicItems?.isEnabled &&
block.options.dynamicItems.pictureSrcsVariableId
) {
const pictureSrcsVariable = variables.find(
(variable) =>
variable.id === block.options.dynamicItems?.pictureSrcsVariableId &&
variable.id === block.options?.dynamicItems?.pictureSrcsVariableId &&
isDefined(variable.value)
) as VariableWithValue | undefined
if (!pictureSrcsVariable) return block
const titlesVariable = block.options.dynamicItems.titlesVariableId
? (variables.find(
(variable) =>
variable.id === block.options.dynamicItems?.titlesVariableId &&
variable.id === block.options?.dynamicItems?.titlesVariableId &&
isDefined(variable.value)
) as VariableWithValue | undefined)
: undefined
@ -37,7 +36,7 @@ export const injectVariableValuesInPictureChoiceBlock =
? (variables.find(
(variable) =>
variable.id ===
block.options.dynamicItems?.descriptionsVariableId &&
block.options?.dynamicItems?.descriptionsVariableId &&
isDefined(variable.value)
) as VariableWithValue | undefined)
: undefined
@ -55,7 +54,6 @@ export const injectVariableValuesInPictureChoiceBlock =
...block,
items: variableValues.filter(isDefined).map((pictureSrc, idx) => ({
id: idx.toString(),
type: ItemType.PICTURE_CHOICE,
blockId: block.id,
pictureSrc,
title: titlesVariableValues?.[idx] ?? '',

View File

@ -8,7 +8,7 @@ export const parsePictureChoicesReply =
const displayedItems = injectVariableValuesInPictureChoiceBlock(
state.typebotsQueue[0].typebot.variables
)(block).items
if (block.options.isMultipleChoice) {
if (block.options?.isMultipleChoice) {
const longestItemsFirst = [...displayedItems].sort(
(a, b) => (b.title?.length ?? 0) - (a.title?.length ?? 0)
)

View File

@ -1,4 +1,5 @@
import { RatingInputBlock } from '@typebot.io/schemas'
import { defaultRatingInputOptions } from '@typebot.io/schemas/features/blocks/inputs/rating/constants'
export const validateRatingReply = (reply: string, block: RatingInputBlock) =>
Number(reply) <= block.options.length
Number(reply) <= (block.options?.length ?? defaultRatingInputOptions.length)

View File

@ -1,16 +1,16 @@
import { ExecuteIntegrationResponse } from '../../../types'
import { env } from '@typebot.io/env'
import { isDefined } from '@typebot.io/lib'
import {
ChatwootBlock,
ChatwootOptions,
SessionState,
} from '@typebot.io/schemas'
import { ChatwootBlock, SessionState } from '@typebot.io/schemas'
import { extractVariablesFromText } from '../../../variables/extractVariablesFromText'
import { parseGuessedValueType } from '../../../variables/parseGuessedValueType'
import { parseVariables } from '../../../variables/parseVariables'
import { defaultChatwootOptions } from '@typebot.io/schemas/features/blocks/integrations/chatwoot/constants'
const parseSetUserCode = (user: ChatwootOptions['user'], resultId: string) =>
const parseSetUserCode = (
user: NonNullable<ChatwootBlock['options']>['user'],
resultId: string
) =>
user?.email || user?.id
? `
window.$chatwoot.setUser(${user?.id ?? `"${resultId}"`}, {
@ -27,7 +27,7 @@ const parseChatwootOpenCode = ({
user,
resultId,
typebotId,
}: ChatwootOptions & { typebotId: string; resultId: string }) => {
}: ChatwootBlock['options'] & { typebotId: string; resultId: string }) => {
const openChatwoot = `${parseSetUserCode(user, resultId)}
if(window.Typebot?.unmount) window.Typebot.unmount();
window.$chatwoot.setCustomAttributes({
@ -46,7 +46,7 @@ const parseChatwootOpenCode = ({
if (window.$chatwoot) {${openChatwoot}}
else {
(function (d, t) {
var BASE_URL = "${baseUrl}";
var BASE_URL = "${baseUrl ?? defaultChatwootOptions.baseUrl}";
var g = d.createElement(t),
s = d.getElementsByTagName(t)[0];
g.src = BASE_URL + "/packs/js/sdk.js";
@ -78,7 +78,7 @@ export const executeChatwootBlock = (
if (state.whatsApp) return { outgoingEdgeId: block.outgoingEdgeId }
const { typebot, resultId } = state.typebotsQueue[0]
const chatwootCode =
block.options.task === 'Close widget'
block.options?.task === 'Close widget'
? chatwootCloseCode
: isDefined(resultId)
? parseChatwootOpenCode({
@ -87,6 +87,7 @@ export const executeChatwootBlock = (
resultId,
})
: ''
return {
outgoingEdgeId: block.outgoingEdgeId,
clientSideActions: [

View File

@ -7,7 +7,7 @@ export const executeGoogleAnalyticsBlock = (
block: GoogleAnalyticsBlock
): ExecuteIntegrationResponse => {
const { typebot, resultId } = state.typebotsQueue[0]
if (!resultId || state.whatsApp)
if (!resultId || state.whatsApp || !block.options)
return { outgoingEdgeId: block.outgoingEdgeId }
const googleAnalytics = deepParseVariables(typebot.variables, {
guessCorrectTypes: true,

View File

@ -1,17 +1,15 @@
import {
GoogleSheetsBlock,
GoogleSheetsAction,
SessionState,
} from '@typebot.io/schemas'
import { GoogleSheetsBlock, SessionState } from '@typebot.io/schemas'
import { insertRow } from './insertRow'
import { updateRow } from './updateRow'
import { getRow } from './getRow'
import { ExecuteIntegrationResponse } from '../../../types'
import { GoogleSheetsAction } from '@typebot.io/schemas/features/blocks/integrations/googleSheets/constants'
export const executeGoogleSheetBlock = async (
state: SessionState,
block: GoogleSheetsBlock
): Promise<ExecuteIntegrationResponse> => {
if (!block.options) return { outgoingEdgeId: block.outgoingEdgeId }
const action = block.options.action
if (!action) return { outgoingEdgeId: block.outgoingEdgeId }
switch (action) {

View File

@ -4,7 +4,7 @@ import {
VariableWithValue,
ReplyLog,
} from '@typebot.io/schemas'
import { isNotEmpty, byId } from '@typebot.io/lib'
import { isNotEmpty, byId, isDefined } from '@typebot.io/lib'
import { getAuthenticatedGoogleDoc } from './helpers/getAuthenticatedGoogleDoc'
import { ExecuteIntegrationResponse } from '../../../types'
import { matchFilter } from './helpers/matchFilter'
@ -20,7 +20,7 @@ export const getRow = async (
): Promise<ExecuteIntegrationResponse> => {
const logs: ReplyLog[] = []
const { variables } = state.typebotsQueue[0].typebot
const { sheetId, cellsToExtract, referenceCell, filter } =
const { sheetId, cellsToExtract, filter, ...parsedOptions } =
deepParseVariables(variables)(options)
if (!sheetId) return { outgoingEdgeId }
@ -36,8 +36,9 @@ export const getRow = async (
const filteredRows = getTotalRows(
options.totalRowsToExtract,
rows.filter((row) =>
referenceCell
? row.get(referenceCell.column as string) === referenceCell.value
'referenceCell' in parsedOptions && parsedOptions.referenceCell
? row.get(parsedOptions.referenceCell?.column as string) ===
parsedOptions.referenceCell?.value
: matchFilter(row, filter)
)
)
@ -50,17 +51,19 @@ export const getRow = async (
return { outgoingEdgeId, logs }
}
const extractingColumns = cellsToExtract
.map((cell) => cell.column)
?.map((cell) => cell.column)
.filter(isNotEmpty)
const selectedRows = filteredRows.map((row) =>
extractingColumns.reduce<{ [key: string]: string }>(
(obj, column) => ({ ...obj, [column]: row.get(column) }),
{}
const selectedRows = filteredRows
.map((row) =>
extractingColumns?.reduce<{ [key: string]: string }>(
(obj, column) => ({ ...obj, [column]: row.get(column) }),
{}
)
)
)
.filter(isDefined)
if (!selectedRows) return { outgoingEdgeId }
const newVariables = options.cellsToExtract.reduce<VariableWithValue[]>(
const newVariables = options.cellsToExtract?.reduce<VariableWithValue[]>(
(newVariables, cell) => {
const existingVariable = variables.find(byId(cell.variableId))
const value = selectedRows.map((row) => row[cell.column ?? ''])
@ -75,6 +78,7 @@ export const getRow = async (
},
[]
)
if (!newVariables) return { outgoingEdgeId }
const newSessionState = updateVariablesInSession(state)(newVariables)
return {
outgoingEdgeId,

View File

@ -3,11 +3,11 @@ import { env } from '@typebot.io/env'
import { encrypt } from '@typebot.io/lib/api/encryption/encrypt'
import { decrypt } from '@typebot.io/lib/api/encryption/decrypt'
import { isDefined } from '@typebot.io/lib/utils'
import { GoogleSheetsCredentials } from '@typebot.io/schemas/features/blocks/integrations/googleSheets/schemas'
import { Credentials as CredentialsFromDb } from '@typebot.io/prisma'
import { GoogleSpreadsheet } from 'google-spreadsheet'
import { OAuth2Client, Credentials } from 'google-auth-library'
import prisma from '@typebot.io/lib/prisma'
import { GoogleSheetsCredentials } from '@typebot.io/schemas'
export const getAuthenticatedGoogleDoc = async ({
credentialsId,

View File

@ -1,9 +1,9 @@
import { isDefined } from '@typebot.io/lib'
import { GoogleSheetsGetOptions } from '@typebot.io/schemas'
import {
GoogleSheetsGetOptions,
LogicalOperator,
ComparisonOperators,
} from '@typebot.io/schemas'
} from '@typebot.io/schemas/features/blocks/logic/condition/constants'
import { GoogleSpreadsheetRow } from 'google-spreadsheet'
export const matchFilter = (
@ -12,7 +12,7 @@ export const matchFilter = (
) => {
if (!filter) return true
return filter.logicalOperator === LogicalOperator.AND
? filter.comparisons.every(
? filter.comparisons?.every(
(comparison) =>
comparison.column &&
matchComparison(
@ -21,7 +21,7 @@ export const matchFilter = (
comparison.value
)
)
: filter.comparisons.some(
: filter.comparisons?.some(
(comparison) =>
comparison.column &&
matchComparison(

View File

@ -17,8 +17,14 @@ export const updateRow = async (
}: { outgoingEdgeId?: string; options: GoogleSheetsUpdateRowOptions }
): Promise<ExecuteIntegrationResponse> => {
const { variables } = state.typebotsQueue[0].typebot
const { sheetId, referenceCell, filter } =
const { sheetId, filter, ...parsedOptions } =
deepParseVariables(variables)(options)
const referenceCell =
'referenceCell' in parsedOptions && parsedOptions.referenceCell
? parsedOptions.referenceCell
: null
if (!options.cellsToUpsert || !sheetId || (!referenceCell && !filter))
return { outgoingEdgeId }

View File

@ -1,6 +1,5 @@
import {
Block,
BubbleBlockType,
Credentials,
SessionState,
TypebotInSession,
@ -8,7 +7,6 @@ import {
import {
ChatCompletionOpenAIOptions,
OpenAICredentials,
chatCompletionMessageRoles,
} from '@typebot.io/schemas/features/blocks/integrations/openai'
import { byId, isEmpty } from '@typebot.io/lib'
import { decrypt } from '@typebot.io/lib/api/encryption/decrypt'
@ -20,6 +18,11 @@ import prisma from '@typebot.io/lib/prisma'
import { ExecuteIntegrationResponse } from '../../../types'
import { parseVariableNumber } from '../../../variables/parseVariableNumber'
import { updateVariablesInSession } from '../../../variables/updateVariablesInSession'
import {
chatCompletionMessageRoles,
defaultOpenAIOptions,
} from '@typebot.io/schemas/features/blocks/integrations/openai/constants'
import { BubbleBlockType } from '@typebot.io/schemas/features/blocks/bubbles/constants'
export const createChatCompletionOpenAI = async (
state: SessionState,
@ -38,6 +41,7 @@ export const createChatCompletionOpenAI = async (
status: 'error',
description: 'Make sure to select an OpenAI account',
}
if (!options.credentialsId) {
return {
outgoingEdgeId,
@ -74,7 +78,7 @@ export const createChatCompletionOpenAI = async (
const assistantMessageVariableName = typebot.variables.find(
(variable) =>
options.responseMapping.find(
options.responseMapping?.find(
(m) => m.valueToExtract === 'Message content'
)?.variableId === variable.id
)?.name
@ -109,7 +113,7 @@ export const createChatCompletionOpenAI = async (
const { chatCompletion, logs } = await executeChatCompletionOpenAIRequest({
apiKey,
messages,
model: options.model,
model: options.model ?? defaultOpenAIOptions.model,
temperature,
baseUrl: options.baseUrl,
apiVersion: options.apiVersion,
@ -140,8 +144,8 @@ const isNextBubbleMessageWithAssistantMessage =
if (!nextBlock) return false
return (
nextBlock.type === BubbleBlockType.TEXT &&
nextBlock.content.richText?.length > 0 &&
nextBlock.content.richText?.at(0)?.children.at(0).text ===
(nextBlock.content?.richText?.length ?? 0) > 0 &&
nextBlock.content?.richText?.at(0)?.children.at(0).text ===
`{{${assistantVariableName}}}`
)
}

View File

@ -12,7 +12,7 @@ type Props = Pick<
temperature: number | undefined
currentLogs?: ChatReply['logs']
isRetrying?: boolean
} & Pick<OpenAIBlock['options'], 'apiVersion' | 'baseUrl'>
} & Pick<NonNullable<OpenAIBlock['options']>, 'apiVersion' | 'baseUrl'>
export const executeChatCompletionOpenAIRequest = async ({
apiKey,

View File

@ -7,7 +7,7 @@ export const executeOpenAIBlock = async (
state: SessionState,
block: OpenAIBlock
): Promise<ExecuteIntegrationResponse> => {
switch (block.options.task) {
switch (block.options?.task) {
case 'Create chat completion':
return createChatCompletionOpenAI(state, {
options: block.options,

View File

@ -9,6 +9,7 @@ import { SessionState } from '@typebot.io/schemas/features/chat/sessionState'
import { OpenAIStream } from 'ai'
import { parseVariableNumber } from '../../../variables/parseVariableNumber'
import { ClientOptions, OpenAI } from 'openai'
import { defaultOpenAIOptions } from '@typebot.io/schemas/features/blocks/integrations/openai/constants'
export const getChatCompletionStream =
(conn: Connection) =>
@ -53,7 +54,7 @@ export const getChatCompletionStream =
const openai = new OpenAI(config)
const response = await openai.chat.completions.create({
model: options.model,
model: options.model ?? defaultOpenAIOptions.model,
temperature,
stream: true,
messages,

View File

@ -15,7 +15,7 @@ export const parseChatCompletionMessages =
} => {
const variablesTransformedToList: VariableWithValue[] = []
const parsedMessages = messages
.flatMap((message) => {
?.flatMap((message) => {
if (!message.role) return
if (message.role === 'Messages sequence ✨') {
if (
@ -71,6 +71,29 @@ export const parseChatCompletionMessages =
return allMessages
}
if (message.role === 'Dialogue') {
if (!message.dialogueVariableId) return
const dialogue = (variables.find(
(variable) => variable.id === message.dialogueVariableId
)?.value ?? []) as string[]
return dialogue.map<OpenAI.Chat.ChatCompletionMessageParam>(
(dialogueItem, index) => {
if (index === 0 && message.startsBy === 'assistant')
return {
role: 'assistant',
content: dialogueItem,
}
return {
role:
index % (message.startsBy === 'assistant' ? 1 : 2) === 0
? 'user'
: 'assistant',
content: dialogueItem,
}
}
)
}
return {
role: message.role,
content: parseVariables(variables)(message.content),
@ -83,6 +106,8 @@ export const parseChatCompletionMessages =
(message) => isNotEmpty(message?.role) && isNotEmpty(message?.content)
) as OpenAI.Chat.ChatCompletionMessageParam[]
console.log('parsedMessages', parsedMessages)
return {
variablesTransformedToList,
messages: parsedMessages,

View File

@ -19,7 +19,7 @@ export const resumeChatCompletion =
) =>
async (message: string, totalTokens?: number) => {
let newSessionState = state
const newVariables = options.responseMapping.reduce<
const newVariables = options.responseMapping?.reduce<
VariableWithUnknowValue[]
>((newVariables, mapping) => {
const { typebot } = newSessionState.typebotsQueue[0]
@ -41,7 +41,7 @@ export const resumeChatCompletion =
}
return newVariables
}, [])
if (newVariables.length > 0)
if (newVariables && newVariables.length > 0)
newSessionState = updateVariablesInSession(newSessionState)(newVariables)
return {
outgoingEdgeId,

View File

@ -9,7 +9,7 @@ export const executePixelBlock = (
const { typebot, resultId } = state.typebotsQueue[0]
if (
!resultId ||
!block.options.pixelId ||
!block.options?.pixelId ||
!block.options.eventType ||
state.whatsApp
)

View File

@ -3,7 +3,6 @@ import {
AnswerInSessionState,
ReplyLog,
SendEmailBlock,
SendEmailOptions,
SessionState,
SmtpCredentials,
TypebotInSession,
@ -20,6 +19,7 @@ import { env } from '@typebot.io/env'
import { ExecuteIntegrationResponse } from '../../../types'
import prisma from '@typebot.io/lib/prisma'
import { parseVariables } from '../../../variables/parseVariables'
import { defaultSendEmailOptions } from '@typebot.io/schemas/features/blocks/integrations/sendEmail/constants'
export const executeSendEmailBlock = async (
state: SessionState,
@ -41,24 +41,34 @@ export const executeSendEmailBlock = async (
}
const bodyUniqueVariable = findUniqueVariableValue(typebot.variables)(
options.body
options?.body
)
const body = bodyUniqueVariable
? stringifyUniqueVariableValueAsHtml(bodyUniqueVariable)
: parseVariables(typebot.variables, { isInsideHtml: true })(
options.body ?? ''
options?.body ?? ''
)
if (!options?.recipients)
return { outgoingEdgeId: block.outgoingEdgeId, logs }
try {
const sendEmailLogs = await sendEmail({
typebot,
answers,
credentialsId: options.credentialsId,
credentialsId:
options.credentialsId ?? defaultSendEmailOptions.credentialsId,
recipients: options.recipients.map(parseVariables(typebot.variables)),
subject: parseVariables(typebot.variables)(options.subject ?? ''),
subject: options.subject
? parseVariables(typebot.variables)(options?.subject)
: undefined,
body,
cc: (options.cc ?? []).map(parseVariables(typebot.variables)),
bcc: (options.bcc ?? []).map(parseVariables(typebot.variables)),
cc: options.cc
? options.cc.map(parseVariables(typebot.variables))
: undefined,
bcc: options.bcc
? options.bcc.map(parseVariables(typebot.variables))
: undefined,
replyTo: options.replyTo
? parseVariables(typebot.variables)(options.replyTo)
: undefined,
@ -91,7 +101,16 @@ const sendEmail = async ({
isBodyCode,
isCustomBody,
fileUrls,
}: SendEmailOptions & {
}: {
credentialsId: string
recipients: string[]
body: string | undefined
subject: string | undefined
cc: string[] | undefined
bcc: string[] | undefined
replyTo: string | undefined
isBodyCode: boolean | undefined
isCustomBody: boolean | undefined
typebot: TypebotInSession
answers: AnswerInSessionState[]
fileUrls?: string | string[]
@ -216,9 +235,10 @@ const getEmailBody = async ({
}: {
typebot: TypebotInSession
answersInSession: AnswerInSessionState[]
} & Pick<SendEmailOptions, 'isCustomBody' | 'isBodyCode' | 'body'>): Promise<
{ html?: string; text?: string } | undefined
> => {
} & Pick<
NonNullable<SendEmailBlock['options']>,
'isCustomBody' | 'isBodyCode' | 'body'
>): Promise<{ html?: string; text?: string } | undefined> => {
if (isCustomBody || (isNotDefined(isCustomBody) && !isEmpty(body)))
return {
html: isBodyCode ? body : undefined,

View File

@ -7,22 +7,23 @@ import {
Webhook,
Variable,
WebhookResponse,
WebhookOptions,
defaultWebhookAttributes,
KeyValue,
ReplyLog,
ExecutableWebhook,
AnswerInSessionState,
} from '@typebot.io/schemas'
import { stringify } from 'qs'
import { omit } from '@typebot.io/lib'
import { isDefined, isEmpty, 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'
import {
HttpMethod,
defaultWebhookAttributes,
} from '@typebot.io/schemas/features/blocks/integrations/webhook/constants'
type ParsedWebhook = ExecutableWebhook & {
basicAuth: { username?: string; password?: string }
@ -35,22 +36,17 @@ export const executeWebhookBlock = async (
): 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)
block.options?.webhook ??
('webhookId' in block
? ((await prisma.webhook.findUnique({
where: { id: block.webhookId },
})) as Webhook | null)
: null)
if (!webhook) return { outgoingEdgeId: block.outgoingEdgeId }
const parsedWebhook = await parseWebhookAttributes(
state,
state.typebotsQueue[0].answers
)(preparedWebhook)
)({ webhook, isCustomBody: block.options?.isCustomBody })
if (!parsedWebhook) {
logs.push({
status: 'error',
@ -58,7 +54,7 @@ export const executeWebhookBlock = async (
})
return { outgoingEdgeId: block.outgoingEdgeId, logs }
}
if (block.options.isExecutedOnClient && !state.whatsApp)
if (block.options?.isExecutedOnClient && !state.whatsApp)
return {
outgoingEdgeId: block.outgoingEdgeId,
clientSideActions: [
@ -78,40 +74,36 @@ export const executeWebhookBlock = async (
})
}
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> => {
async ({
webhook,
isCustomBody,
}: {
webhook: Webhook
isCustomBody?: boolean
}): 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(
const basicAuthHeaderIdx = webhook.headers?.findIndex(
(h) =>
h.key?.toLowerCase() === 'authorization' &&
h.value?.toLowerCase()?.includes('basic')
)
const isUsernamePasswordBasicAuth =
basicAuthHeaderIdx !== -1 &&
webhook.headers[basicAuthHeaderIdx].value?.includes(':')
isDefined(basicAuthHeaderIdx) &&
webhook.headers?.at(basicAuthHeaderIdx)?.value?.includes(':')
if (isUsernamePasswordBasicAuth) {
const [username, password] =
webhook.headers[basicAuthHeaderIdx].value?.slice(6).split(':') ?? []
webhook.headers?.at(basicAuthHeaderIdx)?.value?.slice(6).split(':') ??
[]
basicAuth.username = username
basicAuth.password = password
webhook.headers.splice(basicAuthHeaderIdx, 1)
webhook.headers?.splice(basicAuthHeaderIdx, 1)
}
const headers = convertKeyValueTableToObject(
webhook.headers,
@ -124,9 +116,11 @@ const parseWebhookAttributes =
body: webhook.body,
answers,
variables: typebot.variables,
isCustomBody,
})
const method = webhook.method ?? defaultWebhookAttributes.method
const { data: body, isJson } =
bodyContent && webhook.method !== HttpMethod.GET
bodyContent && method !== HttpMethod.GET
? safeJsonParse(
parseVariables(typebot.variables, {
isInsideJson: !checkIfBodyIsAVariable(bodyContent),
@ -139,7 +133,7 @@ const parseWebhookAttributes =
webhook.url + (queryParams !== '' ? `?${queryParams}` : '')
),
basicAuth,
method: webhook.method,
method,
headers,
body,
isJson,
@ -156,7 +150,7 @@ export const executeWebhook = async (
const request = {
url,
method: method as Method,
headers,
headers: headers ?? {},
...(basicAuth ?? {}),
json:
!contentType?.includes('x-www-form-urlencoded') && body && isJson
@ -222,20 +216,21 @@ const getBodyContent = async ({
body,
answers,
variables,
isCustomBody,
}: {
body?: string | null
answers: AnswerInSessionState[]
variables: Variable[]
isCustomBody?: boolean
}): Promise<string | undefined> => {
if (!body) return
return body === '{{state}}'
return isEmpty(body) && isCustomBody !== true
? JSON.stringify(
parseAnswers({
answers,
variables: getDefinedVariables(variables),
})
)
: body
: body ?? undefined
}
const convertKeyValueTableToObject = (

View File

@ -1,7 +1,5 @@
import {
InputBlock,
InputBlockType,
LogicBlockType,
PublicTypebot,
ResultHeaderCell,
Block,
@ -11,6 +9,8 @@ import {
} from '@typebot.io/schemas'
import { isInputBlock, byId, isNotDefined } from '@typebot.io/lib'
import { parseResultHeader } from '@typebot.io/lib/results'
import { InputBlockType } from '@typebot.io/schemas/features/blocks/inputs/constants'
import { LogicBlockType } from '@typebot.io/schemas/features/blocks/logic/constants'
export const parseSampleResult =
(
@ -58,11 +58,11 @@ const extractLinkedInputBlocks =
extractLinkedInputBlocks(
linkedTypebots.find((t) =>
'typebotId' in t
? t.typebotId === linkedBot.options.typebotId
: t.id === linkedBot.options.typebotId
? t.typebotId === linkedBot.options?.typebotId
: t.id === linkedBot.options?.typebotId
) as Typebot | PublicTypebot,
linkedTypebots
)(linkedBot.options.groupId, 'forward')
)(linkedBot.options?.groupId, 'forward')
)
)
: []
@ -117,7 +117,7 @@ const parseResultSample = (
const getSampleValue = (block: InputBlock): string => {
switch (block.type) {
case InputBlockType.CHOICE:
return block.options.isMultipleChoice
return block.options?.isMultipleChoice
? block.items.map((item) => item.content).join(', ')
: block.items[0]?.content ?? 'Item'
case InputBlockType.DATE:
@ -139,7 +139,7 @@ const getSampleValue = (block: InputBlock): string => {
case InputBlockType.PAYMENT:
return 'Success'
case InputBlockType.PICTURE_CHOICE:
return block.options.isMultipleChoice
return block.options?.isMultipleChoice
? block.items.map((item) => item.title ?? item.pictureSrc).join(', ')
: block.items[0]?.title ?? block.items[0]?.pictureSrc ?? 'Item'
}
@ -178,16 +178,21 @@ const getGroupIds =
) =>
(groupId: string): string[] => {
const groups = typebot.edges.reduce<string[]>((groupIds, edge) => {
const fromGroupId = typebot.groups.find((g) =>
g.blocks.some(
(b) => 'blockId' in edge.from && b.id === edge.from.blockId
)
)?.id
if (!fromGroupId) return groupIds
if (direction === 'forward')
return (!existingGroupIds ||
!existingGroupIds?.includes(edge.to.groupId)) &&
edge.from.groupId === groupId
fromGroupId === groupId
? [...groupIds, edge.to.groupId]
: groupIds
return (!existingGroupIds ||
!existingGroupIds.includes(edge.from.groupId)) &&
return (!existingGroupIds || !existingGroupIds.includes(fromGroupId)) &&
edge.to.groupId === groupId
? [...groupIds, edge.from.groupId]
? [...groupIds, fromGroupId]
: groupIds
}, [])
const newGroups = [...(existingGroupIds ?? []), ...groups]

View File

@ -49,7 +49,7 @@ export const resumeWebhookExecution = ({
}
)
const newVariables = block.options.responseVariableMapping.reduce<
const newVariables = block.options?.responseVariableMapping?.reduce<
VariableWithUnknowValue[]
>((newVariables, varMapping) => {
if (!varMapping?.bodyPath || !varMapping.variableId) return newVariables
@ -66,7 +66,7 @@ export const resumeWebhookExecution = ({
return newVariables
}
}, [])
if (newVariables.length > 0) {
if (newVariables && newVariables.length > 0) {
const newSessionState = updateVariablesInSession(state)(newVariables)
return {
outgoingEdgeId: block.outgoingEdgeId,

View File

@ -20,26 +20,26 @@ export const executeZemanticAiBlock = async (
): Promise<ExecuteIntegrationResponse> => {
let newSessionState = state
const noCredentialsError = {
status: 'error',
description: 'Make sure to select a Zemantic AI account',
}
const zemanticRequestError = {
status: 'error',
description: 'Could not execute Zemantic AI request',
}
if (!block.options?.credentialsId)
return {
outgoingEdgeId: block.outgoingEdgeId,
}
const credentials = await prisma.credentials.findUnique({
where: {
id: block.options.credentialsId,
id: block.options?.credentialsId,
},
})
if (!credentials) {
console.error('Could not find credentials in database')
return {
outgoingEdgeId: block.outgoingEdgeId,
logs: [noCredentialsError],
logs: [
{
status: 'error',
description: 'Make sure to select a Zemantic AI account',
},
],
}
}
const { apiKey } = (await decrypt(
@ -109,7 +109,12 @@ export const executeZemanticAiBlock = async (
console.error(e)
return {
outgoingEdgeId: block.outgoingEdgeId,
logs: [zemanticRequestError],
logs: [
{
status: 'error',
description: 'Could not execute Zemantic AI request',
},
],
}
}

View File

@ -1,5 +1,6 @@
import { AbTestBlock, SessionState } from '@typebot.io/schemas'
import { ExecuteLogicResponse } from '../../../types'
import { defaultAbTestOptions } from '@typebot.io/schemas/features/blocks/logic/abTest/constants'
export const executeAbTest = (
_: SessionState,
@ -7,7 +8,10 @@ export const executeAbTest = (
): ExecuteLogicResponse => {
const aEdgeId = block.items[0].outgoingEdgeId
const random = Math.random() * 100
if (random < block.options.aPercent && aEdgeId) {
if (
random < (block.options?.aPercent ?? defaultAbTestOptions.aPercent) &&
aEdgeId
) {
return { outgoingEdgeId: aEdgeId }
}
const bEdgeId = block.items[1].outgoingEdgeId

View File

@ -1,20 +1,22 @@
import { isNotDefined, isDefined } from '@typebot.io/lib'
import {
Comparison,
ComparisonOperators,
Condition,
LogicalOperator,
Variable,
} from '@typebot.io/schemas'
import { Comparison, Condition, Variable } from '@typebot.io/schemas'
import { findUniqueVariableValue } from '../../../variables/findUniqueVariableValue'
import { parseVariables } from '../../../variables/parseVariables'
import {
LogicalOperator,
ComparisonOperators,
defaultConditionItemContent,
} from '@typebot.io/schemas/features/blocks/logic/condition/constants'
export const executeCondition =
(variables: Variable[]) =>
(condition: Condition): boolean =>
condition.logicalOperator === LogicalOperator.AND
(condition: Condition): boolean => {
if (!condition.comparisons) return false
return (condition.logicalOperator ??
defaultConditionItemContent.logicalOperator) === LogicalOperator.AND
? condition.comparisons.every(executeComparison(variables))
: condition.comparisons.some(executeComparison(variables))
}
const executeComparison =
(variables: Variable[]) =>

View File

@ -7,8 +7,8 @@ export const executeConditionBlock = (
block: ConditionBlock
): ExecuteLogicResponse => {
const { variables } = state.typebotsQueue[0].typebot
const passedCondition = block.items.find((item) =>
executeCondition(variables)(item.content)
const passedCondition = block.items.find(
(item) => item.content && executeCondition(variables)(item.content)
)
return {
outgoingEdgeId: passedCondition

View File

@ -6,22 +6,23 @@ import { JumpBlock } from '@typebot.io/schemas/features/blocks/logic/jump'
export const executeJumpBlock = (
state: SessionState,
{ groupId, blockId }: JumpBlock['options']
{ groupId, blockId }: JumpBlock['options'] = {}
): ExecuteLogicResponse => {
if (!groupId) return { outgoingEdgeId: undefined }
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)
if (!blockToJumpTo)
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 },
to: { groupId, blockId: blockToJumpTo?.id },
})
const newSessionState = addEdgeToTypebot(state, portalEdge)

View File

@ -9,7 +9,7 @@ export const executeScript = (
block: ScriptBlock
): ExecuteLogicResponse => {
const { variables } = state.typebotsQueue[0].typebot
if (!block.options.content || state.whatsApp)
if (!block.options?.content || state.whatsApp)
return { outgoingEdgeId: block.outgoingEdgeId }
const scriptToExecute = parseScriptToExecuteClientSideAction(

View File

@ -76,7 +76,7 @@ const evaluateSetVariableExpression =
const getExpressionToEvaluate =
(state: SessionState) =>
(options: SetVariableBlock['options']): string | null => {
switch (options.type) {
switch (options?.type) {
case 'Contact name':
return state.whatsApp?.contact.name ?? null
case 'Phone number': {
@ -102,6 +102,12 @@ const getExpressionToEvaluate =
return `const itemIndex = ${options.mapListItemParams?.baseListVariableId}.indexOf(${options.mapListItemParams?.baseItemVariableId})
return ${options.mapListItemParams?.targetListVariableId}.at(itemIndex)`
}
case 'Append value(s)': {
return `if(!${options.item}) return ${options.variableId};
if(!${options.variableId}) return [${options.item}];
if(!Array.isArray(${options.variableId})) return [${options.variableId}, ${options.item}];
return (${options.variableId}).concat(${options.item});`
}
case 'Empty': {
return null
}
@ -117,7 +123,7 @@ const getExpressionToEvaluate =
}
case 'Custom':
case undefined: {
return options.expressionToEvaluate ?? null
return options?.expressionToEvaluate ?? null
}
}
}

View File

@ -14,19 +14,21 @@ import { isNotDefined } from '@typebot.io/lib/utils'
import { createResultIfNotExist } from '../../../queries/createResultIfNotExist'
import { executeJumpBlock } from '../jump/executeJumpBlock'
import prisma from '@typebot.io/lib/prisma'
import { defaultTypebotLinkOptions } from '@typebot.io/schemas/features/blocks/logic/typebotLink/constants'
import { saveVisitedEdges } from '../../../queries/saveVisitedEdges'
export const executeTypebotLink = async (
state: SessionState,
block: TypebotLinkBlock
): Promise<ExecuteLogicResponse> => {
const logs: ReplyLog[] = []
const typebotId = block.options.typebotId
const typebotId = block.options?.typebotId
if (
typebotId === 'current' ||
typebotId === state.typebotsQueue[0].typebot.id
) {
return executeJumpBlock(state, {
groupId: block.options.groupId,
groupId: block.options?.groupId,
})
}
if (!typebotId) {
@ -42,7 +44,7 @@ export const executeTypebotLink = async (
logs.push({
status: 'error',
description: `Failed to link typebot`,
details: `Typebot with ID ${block.options.typebotId} not found`,
details: `Typebot with ID ${block.options?.typebotId} not found`,
})
return { outgoingEdgeId: block.outgoingEdgeId, logs }
}
@ -53,7 +55,7 @@ export const executeTypebotLink = async (
)
const nextGroupId =
block.options.groupId ??
block.options?.groupId ??
linkedTypebot.groups.find((group) =>
group.blocks.some((block) => block.type === 'start')
)?.id
@ -61,7 +63,7 @@ export const executeTypebotLink = async (
logs.push({
status: 'error',
description: `Failed to link typebot`,
details: `Group with ID "${block.options.groupId}" not found`,
details: `Group with ID "${block.options?.groupId}" not found`,
})
return { outgoingEdgeId: block.outgoingEdgeId, logs }
}
@ -96,12 +98,14 @@ const addLinkedTypebotToState = async (
}
: currentTypebotInQueue
const shouldMergeResults = block.options.mergeResults !== false
const shouldMergeResults =
currentTypebotInQueue.typebot.version === '6'
? block.options?.mergeResults ?? defaultTypebotLinkOptions.mergeResults
: block.options?.mergeResults !== false
if (
currentTypebotInQueue.resultId &&
currentTypebotInQueue.answers.length === 0 &&
shouldMergeResults
currentTypebotInQueue.answers.length === 0
) {
await createResultIfNotExist({
resultId: currentTypebotInQueue.resultId,
@ -159,11 +163,10 @@ const createResumeEdgeIfNecessary = (
return {
id: createId(),
from: {
groupId: '',
blockId: '',
},
to: {
groupId: nextBlockInGroup.groupId,
groupId: currentGroup.id,
blockId: nextBlockInGroup.id,
},
}
@ -196,6 +199,7 @@ const fetchTypebot = async (state: SessionState, typebotId: string) => {
edges: true,
groups: true,
variables: true,
events: true,
},
})
return typebotInSessionStateSchema.parse(typebot)
@ -208,6 +212,7 @@ const fetchTypebot = async (state: SessionState, typebotId: string) => {
edges: true,
groups: true,
variables: true,
events: true,
},
})
if (!typebot) return null

View File

@ -1,12 +1,13 @@
import { User } from '@typebot.io/prisma'
import {
LogicBlockType,
Block,
PublicTypebot,
Typebot,
TypebotLinkBlock,
} from '@typebot.io/schemas'
import { isDefined } from '@typebot.io/lib'
import { fetchLinkedTypebots } from './fetchLinkedTypebots'
import { LogicBlockType } from '@typebot.io/schemas/features/blocks/logic/constants'
type Props = {
typebots: Pick<PublicTypebot, 'groups'>[]
@ -23,18 +24,18 @@ export const getPreviouslyLinkedTypebots =
.flatMap((typebot) =>
(
typebot.groups
.flatMap((group) => group.blocks)
.flatMap<Block>((group) => group.blocks)
.filter(
(block) =>
block.type === LogicBlockType.TYPEBOT_LINK &&
isDefined(block.options.typebotId) &&
isDefined(block.options?.typebotId) &&
!capturedLinkedBots.some(
(bot) =>
('typebotId' in bot ? bot.typebotId : bot.id) ===
block.options.typebotId
block.options?.typebotId
)
) as TypebotLinkBlock[]
).map((s) => s.options.typebotId)
).map((b) => b.options?.typebotId)
)
.filter(isDefined)
if (linkedTypebotIds.length === 0) return capturedLinkedBots

View File

@ -1,20 +1,27 @@
import { ExecuteLogicResponse } from '../../../types'
import { SessionState, WaitBlock } from '@typebot.io/schemas'
import { parseVariables } from '../../../variables/parseVariables'
import { isNotDefined } from '@typebot.io/lib'
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)
)
if (isNotDefined(parsedSecondsToWaitFor))
return { outgoingEdgeId: block.outgoingEdgeId }
return {
outgoingEdgeId: block.outgoingEdgeId,
clientSideActions:
parsedSecondsToWaitFor || block.options.shouldPause
parsedSecondsToWaitFor || block.options?.shouldPause
? [
{
wait: { secondsToWaitFor: parsedSecondsToWaitFor ?? 0 },

View File

@ -1,25 +1,25 @@
import {
TypingEmulation,
defaultSettings,
} from '@typebot.io/schemas/features/typebot/settings'
import { Settings } from '@typebot.io/schemas/features/typebot/settings'
import { defaultSettings } from '@typebot.io/schemas/features/typebot/settings/constants'
type Props = {
bubbleContent: string
typingSettings?: TypingEmulation
typingSettings?: Settings['typingEmulation']
}
export const computeTypingDuration = ({
bubbleContent,
typingSettings = defaultSettings({ isBrandingEnabled: false })
.typingEmulation,
typingSettings,
}: Props) => {
let wordCount = bubbleContent.match(/(\w+)/g)?.length ?? 0
if (wordCount === 0) wordCount = bubbleContent.length
const typedWordsPerMinute = typingSettings.speed
let typingTimeout = typingSettings.enabled
? (wordCount / typedWordsPerMinute) * 60000
: 0
if (typingTimeout > typingSettings.maxDelay * 1000)
typingTimeout = typingSettings.maxDelay * 1000
const { enabled, speed, maxDelay } = {
enabled: typingSettings?.enabled ?? defaultSettings.typingEmulation.enabled,
speed: typingSettings?.speed ?? defaultSettings.typingEmulation.speed,
maxDelay:
typingSettings?.maxDelay ?? defaultSettings.typingEmulation.maxDelay,
}
const typedWordsPerMinute = speed
let typingTimeout = enabled ? (wordCount / typedWordsPerMinute) * 60000 : 0
if (typingTimeout > maxDelay * 1000) typingTimeout = maxDelay * 1000
return typingTimeout
}

View File

@ -1,15 +1,10 @@
import {
AnswerInSessionState,
Block,
BubbleBlockType,
ChatReply,
Group,
InputBlock,
InputBlockType,
IntegrationBlockType,
LogicBlockType,
SessionState,
defaultPaymentInputOptions,
invalidEmailDefaultRetryMessage,
} from '@typebot.io/schemas'
import { isInputBlock, byId } from '@typebot.io/lib'
import { executeGroup, parseInput } from './executeGroup'
@ -31,6 +26,17 @@ import { updateVariablesInSession } from './variables/updateVariablesInSession'
import { startBotFlow } from './startBotFlow'
import { TRPCError } from '@trpc/server'
import { parseNumber } from './blocks/inputs/number/parseNumber'
import { BubbleBlockType } from '@typebot.io/schemas/features/blocks/bubbles/constants'
import { InputBlockType } from '@typebot.io/schemas/features/blocks/inputs/constants'
import { defaultPaymentInputOptions } from '@typebot.io/schemas/features/blocks/inputs/payment/constants'
import { IntegrationBlockType } from '@typebot.io/schemas/features/blocks/integrations/constants'
import { LogicBlockType } from '@typebot.io/schemas/features/blocks/logic/constants'
import { defaultEmailInputOptions } from '@typebot.io/schemas/features/blocks/inputs/email/constants'
import { defaultChoiceInputOptions } from '@typebot.io/schemas/features/blocks/inputs/choice/constants'
import { defaultPictureChoiceOptions } from '@typebot.io/schemas/features/blocks/inputs/pictureChoice/constants'
import { defaultFileInputOptions } from '@typebot.io/schemas/features/blocks/inputs/file/constants'
import { VisitedEdge } from '@typebot.io/prisma'
import { getBlockById } from '@typebot.io/lib/getBlockById'
type Params = {
version: 1 | 2
@ -39,23 +45,21 @@ type Params = {
export const continueBotFlow = async (
reply: string | undefined,
{ state, version }: Params
): Promise<ChatReply & { newSessionState: SessionState }> => {
): Promise<
ChatReply & { newSessionState: SessionState; visitedEdges: VisitedEdge[] }
> => {
let firstBubbleWasStreamed = false
let newSessionState = { ...state }
const visitedEdges: VisitedEdge[] = []
if (!newSessionState.currentBlock) return startBotFlow({ state, version })
if (!newSessionState.currentBlockId) return startBotFlow({ state, version })
const group = state.typebotsQueue[0].typebot.groups.find(
(group) => group.id === state.currentBlock?.groupId
const { block, group, blockIndex } = getBlockById(
newSessionState.currentBlockId,
state.typebotsQueue[0].typebot.groups
)
const blockIndex =
group?.blocks.findIndex(
(block) => block.id === state.currentBlock?.blockId
) ?? -1
const block = blockIndex >= 0 ? group?.blocks[blockIndex ?? 0] : null
if (!block || !group)
if (!block)
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Group / block not found',
@ -63,7 +67,7 @@ export const continueBotFlow = async (
if (block.type === LogicBlockType.SET_VARIABLE) {
const existingVariable = state.typebotsQueue[0].typebot.variables.find(
byId(block.options.variableId)
byId(block.options?.variableId)
)
if (existingVariable && reply) {
const newVariable = {
@ -81,7 +85,7 @@ export const continueBotFlow = async (
if (result.newSessionState) newSessionState = result.newSessionState
} else if (
block.type === IntegrationBlockType.OPEN_AI &&
block.options.task === 'Create chat completion'
block.options?.task === 'Create chat completion'
) {
firstBubbleWasStreamed = true
if (reply) {
@ -102,20 +106,12 @@ export const continueBotFlow = async (
return {
...(await parseRetryMessage(newSessionState)(block)),
newSessionState,
visitedEdges: [],
}
formattedReply =
'reply' in parsedReplyResult ? parsedReplyResult.reply : undefined
const nextEdgeId = getOutgoingEdgeId(newSessionState)(block, formattedReply)
const itemId = nextEdgeId
? newSessionState.typebotsQueue[0].typebot.edges.find(byId(nextEdgeId))
?.from.itemId
: undefined
newSessionState = await processAndSaveAnswer(
state,
block,
itemId
)(formattedReply)
newSessionState = await processAndSaveAnswer(state, block)(formattedReply)
}
const groupHasMoreBlocks = blockIndex < group.blocks.length - 1
@ -127,8 +123,8 @@ export const continueBotFlow = async (
{
...group,
blocks: group.blocks.slice(blockIndex + 1),
},
{ version, state: newSessionState, firstBubbleWasStreamed }
} as Group,
{ version, state: newSessionState, visitedEdges, firstBubbleWasStreamed }
)
return {
...chatReply,
@ -143,10 +139,13 @@ export const continueBotFlow = async (
newSessionState,
lastMessageNewFormat:
formattedReply !== reply ? formattedReply : undefined,
visitedEdges,
}
const nextGroup = await getNextGroup(newSessionState)(nextEdgeId)
if (nextGroup.visitedEdge) visitedEdges.push(nextGroup.visitedEdge)
newSessionState = nextGroup.newSessionState
if (!nextGroup.group)
@ -155,12 +154,14 @@ export const continueBotFlow = async (
newSessionState,
lastMessageNewFormat:
formattedReply !== reply ? formattedReply : undefined,
visitedEdges,
}
const chatReply = await executeGroup(nextGroup.group, {
version,
state: newSessionState,
firstBubbleWasStreamed,
visitedEdges,
})
return {
@ -170,10 +171,10 @@ export const continueBotFlow = async (
}
const processAndSaveAnswer =
(state: SessionState, block: InputBlock, itemId?: string) =>
(state: SessionState, block: InputBlock) =>
async (reply: string | undefined): Promise<SessionState> => {
if (!reply) return state
let newState = await saveAnswer(state, block, itemId)(reply)
let newState = await saveAnswer(state, block)(reply)
newState = saveVariableValueIfAny(newState, block)(reply)
return newState
}
@ -181,9 +182,9 @@ const processAndSaveAnswer =
const saveVariableValueIfAny =
(state: SessionState, block: InputBlock) =>
(reply: string): SessionState => {
if (!block.options.variableId) return state
if (!block.options?.variableId) return state
const foundVariable = state.typebotsQueue[0].typebot.variables.find(
(variable) => variable.id === block.options.variableId
(variable) => variable.id === block.options?.variableId
)
if (!foundVariable) return state
@ -203,6 +204,7 @@ const parseRetryMessage =
(state: SessionState) =>
async (block: InputBlock): Promise<Pick<ChatReply, 'messages' | 'input'>> => {
const retryMessage =
block.options &&
'retryMessageContent' in block.options &&
block.options.retryMessageContent
? block.options.retryMessageContent
@ -224,34 +226,35 @@ const parseRetryMessage =
const parseDefaultRetryMessage = (block: InputBlock): string => {
switch (block.type) {
case InputBlockType.EMAIL:
return invalidEmailDefaultRetryMessage
return defaultEmailInputOptions.retryMessageContent
case InputBlockType.PAYMENT:
return defaultPaymentInputOptions.retryMessageContent as string
return defaultPaymentInputOptions.retryMessageContent
default:
return 'Invalid message. Please, try again.'
}
}
const saveAnswer =
(state: SessionState, block: InputBlock, itemId?: string) =>
(state: SessionState, block: InputBlock) =>
async (reply: string): Promise<SessionState> => {
const groupId = state.typebotsQueue[0].typebot.groups.find((group) =>
group.blocks.some((blockInGroup) => blockInGroup.id === block.id)
)?.id
if (!groupId) throw new Error('saveAnswer: Group not found')
await upsertAnswer({
block,
answer: {
blockId: block.id,
itemId,
groupId: block.groupId,
groupId,
content: reply,
variableId: block.options.variableId,
variableId: block.options?.variableId,
},
reply,
state,
itemId,
})
const key = block.options.variableId
const key = block.options?.variableId
? state.typebotsQueue[0].typebot.variables.find(
(variable) => variable.id === block.options.variableId
(variable) => variable.id === block.options?.variableId
)?.name
: state.typebotsQueue[0].typebot.groups.find((group) =>
group.blocks.find((blockInGroup) => blockInGroup.id === block.id)
@ -289,7 +292,10 @@ const getOutgoingEdgeId =
const variables = state.typebotsQueue[0].typebot.variables
if (
block.type === InputBlockType.CHOICE &&
!block.options.isMultipleChoice &&
!(
block.options?.isMultipleChoice ??
defaultChoiceInputOptions.isMultipleChoice
) &&
reply
) {
const matchedItem = block.items.find(
@ -301,7 +307,10 @@ const getOutgoingEdgeId =
}
if (
block.type === InputBlockType.PICTURE_CHOICE &&
!block.options.isMultipleChoice &&
!(
block.options?.isMultipleChoice ??
defaultPictureChoiceOptions.isMultipleChoice
) &&
reply
) {
const matchedItem = block.items.find(
@ -328,7 +337,7 @@ const parseReply =
if (!inputValue) return { status: 'fail' }
const formattedPhone = formatPhoneNumber(
inputValue,
block.options.defaultCountryCode
block.options?.defaultCountryCode
)
if (!formattedPhone) return { status: 'fail' }
return { status: 'success', reply: formattedPhone }
@ -358,7 +367,7 @@ const parseReply =
}
case InputBlockType.FILE: {
if (!inputValue)
return block.options.isRequired
return block.options?.isRequired ?? defaultFileInputOptions.isRequired
? { status: 'fail' }
: { status: 'skip' }
return { status: 'success', reply: inputValue }

View File

@ -2,7 +2,6 @@ import {
ChatReply,
Group,
InputBlock,
InputBlockType,
RuntimeOptions,
SessionState,
} from '@typebot.io/schemas'
@ -22,7 +21,12 @@ import { injectVariableValuesInPictureChoiceBlock } from './blocks/inputs/pictur
import { getPrefilledInputValue } from './getPrefilledValue'
import { parseDateInput } from './blocks/inputs/date/parseDateInput'
import { deepParseVariables } from './variables/deepParseVariables'
import { parseBubbleBlock } from './parseBubbleBlock'
import {
BubbleBlockWithDefinedContent,
parseBubbleBlock,
} from './parseBubbleBlock'
import { InputBlockType } from '@typebot.io/schemas/features/blocks/inputs/constants'
import { VisitedEdge } from '@typebot.io/prisma'
type ContextProps = {
version: 1 | 2
@ -30,6 +34,7 @@ type ContextProps = {
currentReply?: ChatReply
currentLastBubbleId?: string
firstBubbleWasStreamed?: boolean
visitedEdges: VisitedEdge[]
}
export const executeGroup = async (
@ -37,11 +42,14 @@ export const executeGroup = async (
{
version,
state,
visitedEdges,
currentReply,
currentLastBubbleId,
firstBubbleWasStreamed,
}: ContextProps
): Promise<ChatReply & { newSessionState: SessionState }> => {
): Promise<
ChatReply & { newSessionState: SessionState; visitedEdges: VisitedEdge[] }
> => {
const messages: ChatReply['messages'] = currentReply?.messages ?? []
let clientSideActions: ChatReply['clientSideActions'] =
currentReply?.clientSideActions
@ -57,11 +65,12 @@ export const executeGroup = async (
nextEdgeId = block.outgoingEdgeId
if (isBubbleBlock(block)) {
if (firstBubbleWasStreamed && index === 0) continue
if (!block.content || (firstBubbleWasStreamed && index === 0)) continue
messages.push(
parseBubbleBlock(block, {
parseBubbleBlock(block as BubbleBlockWithDefinedContent, {
version,
variables: newSessionState.typebotsQueue[0].typebot.variables,
typebotVersion: newSessionState.typebotsQueue[0].typebot.version,
})
)
lastBubbleBlockId = block.id
@ -74,13 +83,11 @@ export const executeGroup = async (
input: await parseInput(newSessionState)(block),
newSessionState: {
...newSessionState,
currentBlock: {
groupId: group.id,
blockId: block.id,
},
currentBlockId: block.id,
},
clientSideActions,
logs,
visitedEdges,
}
const executionResponse = isLogicBlock(block)
? await executeLogic(newSessionState)(block)
@ -113,13 +120,11 @@ export const executeGroup = async (
messages,
newSessionState: {
...newSessionState,
currentBlock: {
groupId: group.id,
blockId: block.id,
},
currentBlockId: block.id,
},
clientSideActions,
logs,
visitedEdges,
}
}
}
@ -131,19 +136,22 @@ export const executeGroup = async (
}
if (!nextEdgeId && newSessionState.typebotsQueue.length === 1)
return { messages, newSessionState, clientSideActions, logs }
return { messages, newSessionState, clientSideActions, logs, visitedEdges }
const nextGroup = await getNextGroup(newSessionState)(nextEdgeId ?? undefined)
newSessionState = nextGroup.newSessionState
if (nextGroup.visitedEdge) visitedEdges.push(nextGroup.visitedEdge)
if (!nextGroup.group) {
return { messages, newSessionState, clientSideActions, logs }
return { messages, newSessionState, clientSideActions, logs, visitedEdges }
}
return executeGroup(nextGroup.group, {
version,
state: newSessionState,
visitedEdges,
currentReply: {
messages,
clientSideActions,
@ -188,14 +196,14 @@ export const parseInput =
...parsedBlock,
options: {
...parsedBlock.options,
min: isNotEmpty(parsedBlock.options.min as string)
? Number(parsedBlock.options.min)
min: isNotEmpty(parsedBlock.options?.min as string)
? Number(parsedBlock.options?.min)
: undefined,
max: isNotEmpty(parsedBlock.options.max as string)
? Number(parsedBlock.options.max)
max: isNotEmpty(parsedBlock.options?.max as string)
? Number(parsedBlock.options?.max)
: undefined,
step: isNotEmpty(parsedBlock.options.step as string)
? Number(parsedBlock.options.step)
step: isNotEmpty(parsedBlock.options?.step as string)
? Number(parsedBlock.options?.step)
: undefined,
},
}

View File

@ -6,12 +6,9 @@ import { executeGoogleAnalyticsBlock } from './blocks/integrations/googleAnalyti
import { executeGoogleSheetBlock } from './blocks/integrations/googleSheets/executeGoogleSheetBlock'
import { executePixelBlock } from './blocks/integrations/pixel/executePixelBlock'
import { executeZemanticAiBlock } from './blocks/integrations/zemanticAi/executeZemanticAiBlock'
import {
IntegrationBlock,
IntegrationBlockType,
SessionState,
} from '@typebot.io/schemas'
import { IntegrationBlock, SessionState } from '@typebot.io/schemas'
import { ExecuteIntegrationResponse } from './types'
import { IntegrationBlockType } from '@typebot.io/schemas/features/blocks/integrations/constants'
export const executeIntegration =
(state: SessionState) =>

View File

@ -1,5 +1,5 @@
import { executeWait } from './blocks/logic/wait/executeWait'
import { LogicBlock, LogicBlockType, SessionState } from '@typebot.io/schemas'
import { LogicBlock, SessionState } from '@typebot.io/schemas'
import { ExecuteLogicResponse } from './types'
import { executeScript } from './blocks/logic/script/executeScript'
import { executeJumpBlock } from './blocks/logic/jump/executeJumpBlock'
@ -8,6 +8,7 @@ import { executeConditionBlock } from './blocks/logic/condition/executeCondition
import { executeSetVariable } from './blocks/logic/setVariable/executeSetVariable'
import { executeTypebotLink } from './blocks/logic/typebotLink/executeTypebotLink'
import { executeAbTest } from './blocks/logic/abTest/executeAbTest'
import { LogicBlockType } from '@typebot.io/schemas/features/blocks/logic/constants'
export const executeLogic =
(state: SessionState) =>

View File

@ -1,10 +1,12 @@
import { byId, isDefined, isNotDefined } from '@typebot.io/lib'
import { Group, SessionState, VariableWithValue } from '@typebot.io/schemas'
import { upsertResult } from './queries/upsertResult'
import { VisitedEdge } from '@typebot.io/prisma'
export type NextGroup = {
group?: Group
newSessionState: SessionState
visitedEdge?: VisitedEdge
}
export const getNextGroup =
@ -93,11 +95,23 @@ export const getNextGroup =
const startBlockIndex = nextEdge.to.blockId
? nextGroup.blocks.findIndex(byId(nextEdge.to.blockId))
: 0
const currentVisitedEdgeIndex = (state.currentVisitedEdgeIndex ?? -1) + 1
const resultId = state.typebotsQueue[0].resultId
return {
group: {
...nextGroup,
blocks: nextGroup.blocks.slice(startBlockIndex),
} as Group,
newSessionState: {
...state,
currentVisitedEdgeIndex,
},
newSessionState: state,
visitedEdge: resultId
? {
index: currentVisitedEdgeIndex,
edgeId: nextEdge.id,
resultId,
}
: undefined,
}
}

View File

@ -1,12 +1,12 @@
import { isDefined } from '@typebot.io/lib/utils'
import { InputBlock } from '@typebot.io/schemas/features/blocks/schemas'
import { InputBlock } from '@typebot.io/schemas'
import { Variable } from '@typebot.io/schemas/features/typebot/variable'
export const getPrefilledInputValue =
(variables: Variable[]) => (block: InputBlock) => {
const variableValue = variables.find(
(variable) =>
variable.id === block.options.variableId && isDefined(variable.value)
variable.id === block.options?.variableId && isDefined(variable.value)
)?.value
if (!variableValue || Array.isArray(variableValue)) return
return variableValue

View File

@ -1,10 +1,5 @@
import { parseVideoUrl } from '@typebot.io/lib/parseVideoUrl'
import {
BubbleBlock,
Variable,
ChatReply,
BubbleBlockType,
} from '@typebot.io/schemas'
import { BubbleBlock, Variable, ChatReply, Typebot } from '@typebot.io/schemas'
import { deepParseVariables } from './variables/deepParseVariables'
import { isEmpty, isNotEmpty } from '@typebot.io/lib/utils'
import {
@ -16,31 +11,42 @@ import {
createDeserializeMdPlugin,
deserializeMd,
} from '@udecode/plate-serializer-md'
import { BubbleBlockType } from '@typebot.io/schemas/features/blocks/bubbles/constants'
import { defaultVideoBubbleContent } from '@typebot.io/schemas/features/blocks/bubbles/video/constants'
type Params = {
version: 1 | 2
typebotVersion: Typebot['version']
variables: Variable[]
}
export type BubbleBlockWithDefinedContent = BubbleBlock & {
content: NonNullable<BubbleBlock['content']>
}
export const parseBubbleBlock = (
block: BubbleBlock,
{ version, variables }: Params
block: BubbleBlockWithDefinedContent,
{ version, variables, typebotVersion }: Params
): ChatReply['messages'][0] => {
switch (block.type) {
case BubbleBlockType.TEXT: {
if (version === 1)
return deepParseVariables(
variables,
{},
{ takeLatestIfList: true }
)(block)
return {
...block,
content: {
...block.content,
richText: (block.content?.richText ?? []).map(
deepParseVariables(variables)
),
},
}
return {
...block,
content: {
...block.content,
richText: parseVariablesInRichText(block.content.richText, {
richText: parseVariablesInRichText(block.content?.richText ?? [], {
variables,
takeLatestIfList: true,
takeLatestIfList: typebotVersion !== '6',
}),
},
}
@ -53,22 +59,24 @@ export const parseBubbleBlock = (
content: {
...message.content,
height:
typeof message.content.height === 'string'
typeof message.content?.height === 'string'
? parseFloat(message.content.height)
: message.content.height,
: message.content?.height,
},
}
}
case BubbleBlockType.VIDEO: {
const parsedContent = deepParseVariables(variables)(block.content)
const parsedContent = block.content
? deepParseVariables(variables)(block.content)
: undefined
return {
...block,
content: {
...(parsedContent.url ? parseVideoUrl(parsedContent.url) : {}),
...(parsedContent?.url ? parseVideoUrl(parsedContent.url) : {}),
height:
typeof parsedContent.height === 'string'
typeof parsedContent?.height === 'string'
? parseFloat(parsedContent.height)
: parsedContent.height,
: defaultVideoBubbleContent.height,
},
}
}

View File

@ -8,6 +8,7 @@ type Props = {
hasStarted: boolean
isCompleted: boolean
}
export const createResultIfNotExist = async ({
resultId,
typebot,

View File

@ -10,6 +10,7 @@ export const findPublicTypebot = ({ publicId }: Props) =>
select: {
version: true,
groups: true,
events: true,
edges: true,
settings: true,
theme: true,

View File

@ -12,6 +12,7 @@ export const findTypebot = ({ id, userId }: Props) =>
version: true,
id: true,
groups: true,
events: true,
edges: true,
settings: true,
theme: true,

View File

@ -0,0 +1,8 @@
import prisma from '@typebot.io/lib/prisma'
import { VisitedEdge } from '@typebot.io/prisma'
export const saveVisitedEdges = (visitedEdges: VisitedEdge[]) =>
prisma.visitedEdge.createMany({
data: visitedEdges,
skipDuplicates: true,
})

View File

@ -4,18 +4,16 @@ import { InputBlock, SessionState } from '@typebot.io/schemas'
type Props = {
answer: Omit<Prisma.AnswerUncheckedCreateInput, 'resultId'>
block: InputBlock
reply: string
itemId?: string
state: SessionState
}
export const upsertAnswer = async ({ answer, block, state }: Props) => {
export const upsertAnswer = async ({ answer, state }: Props) => {
const resultId = state.typebotsQueue[0].resultId
if (!resultId) return
const where = {
resultId,
blockId: block.id,
groupId: block.groupId,
blockId: answer.blockId,
groupId: answer.groupId,
}
const existingAnswer = await prisma.answer.findUnique({
where: {
@ -28,7 +26,6 @@ export const upsertAnswer = async ({ answer, block, state }: Props) => {
where,
data: {
content: answer.content,
itemId: answer.itemId,
},
})
return prisma.answer.createMany({

View File

@ -6,12 +6,15 @@ import { formatLogDetails } from './logs/helpers/formatLogDetails'
import { createSession } from './queries/createSession'
import { deleteSession } from './queries/deleteSession'
import * as Sentry from '@sentry/nextjs'
import { saveVisitedEdges } from './queries/saveVisitedEdges'
import { VisitedEdge } from '@typebot.io/prisma'
type Props = {
session: Pick<ChatSession, 'state'> & { id?: string }
input: ChatReply['input']
logs: ChatReply['logs']
clientSideActions: ChatReply['clientSideActions']
visitedEdges: VisitedEdge[]
forceCreateSession?: boolean
}
@ -21,6 +24,7 @@ export const saveStateToDatabase = async ({
logs,
clientSideActions,
forceCreateSession,
visitedEdges,
}: Props) => {
const containsSetVariableClientSideAction = clientSideActions?.some(
(action) => action.expectsDedicatedReply
@ -67,5 +71,7 @@ export const saveStateToDatabase = async ({
Sentry.captureException(e)
}
if (visitedEdges.length > 0) await saveVisitedEdges(visitedEdges)
return session
}

View File

@ -1,36 +1,71 @@
import { TRPCError } from '@trpc/server'
import { ChatReply, SessionState } from '@typebot.io/schemas'
import { ChatReply, SessionState, StartElementId } from '@typebot.io/schemas'
import { executeGroup } from './executeGroup'
import { getNextGroup } from './getNextGroup'
import { VisitedEdge } from '@typebot.io/prisma'
type Props = {
version: 1 | 2
state: SessionState
startGroupId?: string
}
} & StartElementId
export const startBotFlow = async ({
version,
state,
startGroupId,
}: Props): Promise<ChatReply & { newSessionState: SessionState }> => {
...props
}: Props): Promise<
ChatReply & { newSessionState: SessionState; visitedEdges: VisitedEdge[] }
> => {
let newSessionState = state
if (startGroupId) {
const visitedEdges: VisitedEdge[] = []
if ('startGroupId' in props) {
const group = state.typebotsQueue[0].typebot.groups.find(
(group) => group.id === startGroupId
(group) => group.id === props.startGroupId
)
if (!group)
throw new TRPCError({
code: 'BAD_REQUEST',
message: "startGroupId doesn't exist",
message: "Start group doesn't exist",
})
return executeGroup(group, { version, state: newSessionState })
return executeGroup(group, {
version,
state: newSessionState,
visitedEdges,
})
}
const firstEdgeId =
newSessionState.typebotsQueue[0].typebot.groups[0].blocks[0].outgoingEdgeId
if (!firstEdgeId) return { messages: [], newSessionState }
const firstEdgeId = getFirstEdgeId({
state: newSessionState,
startEventId: 'startEventId' in props ? props.startEventId : undefined,
})
if (!firstEdgeId) return { messages: [], newSessionState, visitedEdges: [] }
const nextGroup = await getNextGroup(newSessionState)(firstEdgeId)
newSessionState = nextGroup.newSessionState
if (!nextGroup.group) return { messages: [], newSessionState }
return executeGroup(nextGroup.group, { version, state: newSessionState })
if (nextGroup.visitedEdge) visitedEdges.push(nextGroup.visitedEdge)
if (!nextGroup.group) return { messages: [], newSessionState, visitedEdges }
return executeGroup(nextGroup.group, {
version,
state: newSessionState,
visitedEdges,
})
}
const getFirstEdgeId = ({
state,
startEventId,
}: {
state: SessionState
startEventId: string | undefined
}) => {
const { typebot } = state.typebotsQueue[0]
if (startEventId) {
const event = typebot.events?.find((e) => e.id === startEventId)
if (!event)
throw new TRPCError({
code: 'BAD_REQUEST',
message: "Start event doesn't exist",
})
return event.outgoingEdgeId
}
if (typebot.version === '6') return typebot.events[0].outgoingEdgeId
return typebot.groups[0].blocks[0].outgoingEdgeId
}

View File

@ -5,10 +5,11 @@ import {
Variable,
VariableWithValue,
Theme,
IntegrationBlockType,
GoogleAnalyticsBlock,
PixelBlock,
SessionState,
TypebotInSession,
Block,
} from '@typebot.io/schemas'
import {
ChatReply,
@ -30,6 +31,10 @@ import { getNextGroup } from './getNextGroup'
import { upsertResult } from './queries/upsertResult'
import { continueBotFlow } from './continueBotFlow'
import { parseVariables } from './variables/parseVariables'
import { defaultSettings } from '@typebot.io/schemas/features/typebot/settings/constants'
import { IntegrationBlockType } from '@typebot.io/schemas/features/blocks/integrations/constants'
import { defaultTheme } from '@typebot.io/schemas/features/typebot/theme/constants'
import { VisitedEdge } from '@typebot.io/prisma'
type Props = {
version: 1 | 2
@ -45,7 +50,9 @@ export const startSession = async ({
startParams,
userId,
initialSessionState,
}: Props): Promise<ChatReply & { newSessionState: SessionState }> => {
}: Props): Promise<
ChatReply & { newSessionState: SessionState; visitedEdges: VisitedEdge[] }
> => {
if (!startParams)
throw new TRPCError({
code: 'BAD_REQUEST',
@ -64,10 +71,10 @@ export const startSession = async ({
typebotId: typebot.id,
prefilledVariables,
isRememberUserEnabled:
typebot.settings.general.rememberUser?.isEnabled ??
(isDefined(typebot.settings.general.isNewResultOnRefreshEnabled)
? !typebot.settings.general.isNewResultOnRefreshEnabled
: false),
typebot.settings.general?.rememberUser?.isEnabled ??
(isDefined(typebot.settings.general?.isNewResultOnRefreshEnabled)
? !typebot.settings.general?.isNewResultOnRefreshEnabled
: defaultSettings.general.rememberUser.isEnabled),
})
const startVariables =
@ -75,22 +82,21 @@ export const startSession = async ({
? injectVariablesFromExistingResult(prefilledVariables, result.variables)
: prefilledVariables
const typebotInSession = convertStartTypebotToTypebotInSession(
typebot,
startVariables
)
const initialState: SessionState = {
version: '2',
version: '3',
typebotsQueue: [
{
resultId: result?.id,
typebot: {
version: typebot.version,
id: typebot.id,
groups: typebot.groups,
edges: typebot.edges,
variables: startVariables,
},
typebot: typebotInSession,
answers: result
? result.answers.map((answer) => {
const block = typebot.groups
.flatMap((group) => group.blocks)
.flatMap<Block>((group) => group.blocks)
.find((block) => block.id === answer.blockId)
if (!block || !isInputBlock(block))
return {
@ -98,9 +104,9 @@ export const startSession = async ({
value: answer.content,
}
const key =
(block.options.variableId
(block.options?.variableId
? startVariables.find(
(variable) => variable.id === block.options.variableId
(variable) => variable.id === block.options?.variableId
)?.name
: typebot.groups.find((group) =>
group.blocks.find(
@ -135,13 +141,18 @@ export const startSession = async ({
},
dynamicTheme: parseDynamicTheme(initialState),
messages: [],
visitedEdges: [],
}
}
let chatReply = await startBotFlow({
version,
state: initialState,
startGroupId: startParams.startGroupId,
...('startGroupId' in startParams
? { startGroupId: startParams.startGroupId }
: 'startEventId' in startParams
? { startEventId: startParams.startEventId }
: {}),
})
// If params has message and first block is an input block, we can directly continue the bot flow
@ -165,7 +176,7 @@ export const startSession = async ({
version,
state: {
...newSessionState,
currentBlock: { groupId: firstBlock.groupId, blockId: firstBlock.id },
currentBlockId: firstBlock.id,
},
})
}
@ -177,6 +188,7 @@ export const startSession = async ({
clientSideActions: startFlowClientActions,
newSessionState,
logs,
visitedEdges,
} = chatReply
const clientSideActions = startFlowClientActions ?? []
@ -188,12 +200,12 @@ export const startSession = async ({
if (isDefined(startClientSideAction)) {
if (!result) {
if ('startPropsToInject' in startClientSideAction) {
const { customHeadCode, googleAnalyticsId, pixelId, pixelIds, gtmId } =
const { customHeadCode, googleAnalyticsId, pixelIds, gtmId } =
startClientSideAction.startPropsToInject
let toolsList = ''
if (customHeadCode) toolsList += 'Custom head code, '
if (googleAnalyticsId) toolsList += 'Google Analytics, '
if (pixelId || pixelIds) toolsList += 'Pixel, '
if (pixelIds) toolsList += 'Pixel, '
if (gtmId) toolsList += 'Google Tag Manager, '
toolsList = toolsList.slice(0, -2)
startLogs.push({
@ -229,6 +241,7 @@ export const startSession = async ({
},
dynamicTheme: parseDynamicTheme(newSessionState),
logs: startLogs.length > 0 ? startLogs : undefined,
visitedEdges,
}
return {
@ -249,6 +262,7 @@ export const startSession = async ({
clientSideActions.length > 0 ? clientSideActions : undefined,
dynamicTheme: parseDynamicTheme(newSessionState),
logs: startLogs.length > 0 ? startLogs : undefined,
visitedEdges,
}
}
@ -341,12 +355,13 @@ const getResult = async ({
const parseDynamicThemeInState = (theme: Theme) => {
const hostAvatarUrl =
theme.chat.hostAvatar?.isEnabled ?? true
? theme.chat.hostAvatar?.url
theme.chat?.hostAvatar?.isEnabled ?? defaultTheme.chat.hostAvatar.isEnabled
? theme.chat?.hostAvatar?.url
: undefined
const guestAvatarUrl =
theme.chat.guestAvatar?.isEnabled ?? false
? theme.chat.guestAvatar?.url
theme.chat?.guestAvatar?.isEnabled ??
defaultTheme.chat.guestAvatar.isEnabled
? theme.chat?.guestAvatar?.url
: undefined
if (!hostAvatarUrl?.startsWith('{{') && !guestAvatarUrl?.startsWith('{{'))
return
@ -361,28 +376,30 @@ const parseDynamicThemeInState = (theme: Theme) => {
const parseStartClientSideAction = (
typebot: StartTypebot
): NonNullable<ChatReply['clientSideActions']>[number] | undefined => {
const blocks = typebot.groups.flatMap((group) => group.blocks)
const blocks = typebot.groups.flatMap<Block>((group) => group.blocks)
const pixelBlocks = (
blocks.filter(
(block) =>
block.type === IntegrationBlockType.PIXEL &&
isNotEmpty(block.options.pixelId) &&
block.options.isInitSkip !== true
isNotEmpty(block.options?.pixelId) &&
block.options?.isInitSkip !== true
) as PixelBlock[]
).map((pixelBlock) => pixelBlock.options.pixelId as string)
).map((pixelBlock) => pixelBlock.options?.pixelId as string)
const startPropsToInject = {
customHeadCode: isNotEmpty(typebot.settings.metadata.customHeadCode)
? sanitizeAndParseHeadCode(typebot.settings.metadata.customHeadCode)
customHeadCode: isNotEmpty(typebot.settings.metadata?.customHeadCode)
? sanitizeAndParseHeadCode(
typebot.settings.metadata?.customHeadCode as string
)
: undefined,
gtmId: typebot.settings.metadata.googleTagManagerId,
gtmId: typebot.settings.metadata?.googleTagManagerId,
googleAnalyticsId: (
blocks.find(
(block) =>
block.type === IntegrationBlockType.GOOGLE_ANALYTICS &&
block.options.trackingId
block.options?.trackingId
) as GoogleAnalyticsBlock | undefined
)?.options.trackingId,
)?.options?.trackingId,
pixelIds: pixelBlocks.length > 0 ? pixelBlocks : undefined,
}
@ -403,8 +420,10 @@ const sanitizeAndParseTheme = (
theme: Theme,
{ variables }: { variables: Variable[] }
): Theme => ({
general: deepParseVariables(variables)(theme.general),
chat: deepParseVariables(variables)(theme.chat),
general: theme.general
? deepParseVariables(variables)(theme.general)
: undefined,
chat: theme.chat ? deepParseVariables(variables)(theme.chat) : undefined,
customCss: theme.customCss
? removeLiteBadgeCss(parseVariables(variables)(theme.customCss))
: undefined,
@ -421,3 +440,25 @@ const removeLiteBadgeCss = (code: string) => {
const liteBadgeCssRegex = /.*#lite-badge.*{[\s\S][^{]*}/gm
return code.replace(liteBadgeCssRegex, '')
}
const convertStartTypebotToTypebotInSession = (
typebot: StartTypebot,
startVariables: Variable[]
): TypebotInSession =>
typebot.version === '6'
? {
version: typebot.version,
id: typebot.id,
groups: typebot.groups,
edges: typebot.edges,
variables: startVariables,
events: typebot.events,
}
: {
version: typebot.version,
id: typebot.id,
groups: typebot.groups,
edges: typebot.edges,
variables: startVariables,
events: typebot.events,
}

View File

@ -1,6 +1,7 @@
import { safeStringify } from '@typebot.io/lib/safeStringify'
import { isDefined } from '@typebot.io/lib/utils'
import { isDefined, isNotDefined } from '@typebot.io/lib/utils'
import { Variable, VariableWithValue } from '@typebot.io/schemas'
import { parseGuessedValueType } from './parseGuessedValueType'
export type ParseVariablesOptions = {
fieldToParse?: 'value' | 'id'
@ -16,6 +17,12 @@ export const defaultParseVariablesOptions: ParseVariablesOptions = {
isInsideHtml: false,
}
// {{= inline code =}}
const inlineCodeRegex = /\{\{=(.+?)=\}\}/g
// {{variable}} and ${{{variable}}}
const variableRegex = /\{\{([^{}]+)\}\}|(\$)\{\{([^{}]+)\}\}/g
export const parseVariables =
(
variables: Variable[],
@ -23,10 +30,14 @@ export const parseVariables =
) =>
(text: string | undefined): string => {
if (!text || text === '') return ''
// Capture {{variable}} and ${{{variable}}} (variables in template litterals)
const pattern = /\{\{([^{}]+)\}\}|(\$)\{\{([^{}]+)\}\}/g
return text.replace(
pattern,
const textWithInlineCodeParsed = text.replace(
inlineCodeRegex,
(_full, inlineCodeToEvaluate) =>
evaluateInlineCode(inlineCodeToEvaluate, { variables })
)
return textWithInlineCodeParsed.replace(
variableRegex,
(_full, nameInCurlyBraces, _dollarSign, nameInTemplateLitteral) => {
const dollarSign = (_dollarSign ?? '') as string
const matchedVarName = nameInCurlyBraces ?? nameInTemplateLitteral
@ -55,6 +66,22 @@ export const parseVariables =
)
}
const evaluateInlineCode = (
code: string,
{ variables }: { variables: Variable[] }
) => {
const evaluating = parseVariables(variables, { fieldToParse: 'id' })(
code.includes('return ') ? code : `return ${code}`
)
try {
const func = Function(...variables.map((v) => v.id), evaluating)
return func(...variables.map((v) => parseGuessedValueType(v.value)))
} catch (err) {
console.log(err)
return parseVariables(variables)(code)
}
}
type VariableToParseInformation = {
startIndex: number
endIndex: number
@ -69,10 +96,34 @@ export const getVariablesToParseInfoInText = (
takeLatestIfList,
}: { variables: Variable[]; takeLatestIfList?: boolean }
): VariableToParseInformation[] => {
const pattern = /\{\{([^{}]+)\}\}|(\$)\{\{([^{}]+)\}\}/g
const variablesToParseInfo: VariableToParseInformation[] = []
let match
while ((match = pattern.exec(text)) !== null) {
const inlineCodeMatches = [...text.matchAll(inlineCodeRegex)]
inlineCodeMatches.forEach((match) => {
if (isNotDefined(match.index) || !match[0].length) return
const inlineCodeToEvaluate = match[1]
const evaluatedValue = evaluateInlineCode(inlineCodeToEvaluate, {
variables,
})
variablesToParseInfo.push({
startIndex: match.index,
endIndex: match.index + match[0].length,
textToReplace: match[0],
value:
safeStringify(
takeLatestIfList && Array.isArray(evaluatedValue)
? evaluatedValue[evaluatedValue.length - 1]
: evaluatedValue
) ?? '',
})
})
const textWithInlineCodeParsed = text.replace(
inlineCodeRegex,
(_full, inlineCodeToEvaluate) =>
evaluateInlineCode(inlineCodeToEvaluate, { variables })
)
const variableMatches = [...textWithInlineCodeParsed.matchAll(variableRegex)]
variableMatches.forEach((match) => {
if (isNotDefined(match.index) || !match[0].length) return
const matchedVarName = match[1] ?? match[3]
const variable = variables.find((variable) => {
return matchedVarName === variable.name && isDefined(variable.value)
@ -88,7 +139,7 @@ export const getVariablesToParseInfoInText = (
: variable?.value
) ?? '',
})
}
})
return variablesToParseInfo
}

View File

@ -1,12 +1,11 @@
import {
BubbleBlockType,
ButtonItem,
ChatReply,
InputBlockType,
} from '@typebot.io/schemas'
import { ButtonItem, ChatReply } from '@typebot.io/schemas'
import { WhatsAppSendingMessage } from '@typebot.io/schemas/features/whatsapp'
import { convertRichTextToWhatsAppText } from './convertRichTextToWhatsAppText'
import { isDefined, isEmpty } from '@typebot.io/lib/utils'
import { BubbleBlockType } from '@typebot.io/schemas/features/blocks/bubbles/constants'
import { InputBlockType } from '@typebot.io/schemas/features/blocks/inputs/constants'
import { defaultPictureChoiceOptions } from '@typebot.io/schemas/features/blocks/inputs/pictureChoice/constants'
import { defaultChoiceInputOptions } from '@typebot.io/schemas/features/blocks/inputs/choice/constants'
export const convertInputToWhatsAppMessages = (
input: NonNullable<ChatReply['input']>,
@ -14,7 +13,7 @@ export const convertInputToWhatsAppMessages = (
): WhatsAppSendingMessage[] => {
const lastMessageText =
lastMessage?.type === BubbleBlockType.TEXT
? convertRichTextToWhatsAppText(lastMessage.content.richText)
? convertRichTextToWhatsAppText(lastMessage.content.richText ?? [])
: undefined
switch (input.type) {
case InputBlockType.DATE:
@ -28,7 +27,10 @@ export const convertInputToWhatsAppMessages = (
case InputBlockType.TEXT:
return []
case InputBlockType.PICTURE_CHOICE: {
if (input.options.isMultipleChoice)
if (
input.options?.isMultipleChoice ??
defaultPictureChoiceOptions.isMultipleChoice
)
return input.items.flatMap((item, idx) => {
let bodyText = ''
if (item.title) bodyText += `*${item.title}*`
@ -88,7 +90,10 @@ export const convertInputToWhatsAppMessages = (
})
}
case InputBlockType.CHOICE: {
if (input.options.isMultipleChoice)
if (
input.options?.isMultipleChoice ??
defaultChoiceInputOptions.isMultipleChoice
)
return [
{
type: 'text',

View File

@ -1,11 +1,9 @@
import {
BubbleBlockType,
ChatReply,
VideoBubbleContentType,
} from '@typebot.io/schemas'
import { ChatReply } from '@typebot.io/schemas'
import { WhatsAppSendingMessage } from '@typebot.io/schemas/features/whatsapp'
import { convertRichTextToWhatsAppText } from './convertRichTextToWhatsAppText'
import { isSvgSrc } from '@typebot.io/lib/utils'
import { BubbleBlockType } from '@typebot.io/schemas/features/blocks/bubbles/constants'
import { VideoBubbleContentType } from '@typebot.io/schemas/features/blocks/bubbles/video/constants'
const mp4HttpsUrlRegex = /^https:\/\/.*\.mp4$/

View File

@ -84,8 +84,14 @@ export const resumeWhatsAppFlow = async ({
}
}
const { input, logs, newSessionState, messages, clientSideActions } =
resumeResponse
const {
input,
logs,
newSessionState,
messages,
clientSideActions,
visitedEdges,
} = resumeResponse
await sendChatReplyToWhatsApp({
to: receivedMessage.from,
@ -106,9 +112,10 @@ export const resumeWhatsAppFlow = async ({
id: sessionId,
state: {
...newSessionState,
currentBlock: !input ? undefined : newSessionState.currentBlock,
currentBlockId: !input ? undefined : newSessionState.currentBlockId,
},
},
visitedEdges,
})
return {

View File

@ -1,9 +1,4 @@
import {
ChatReply,
InputBlockType,
SessionState,
Settings,
} from '@typebot.io/schemas'
import { ChatReply, SessionState, Settings } from '@typebot.io/schemas'
import {
WhatsAppCredentials,
WhatsAppSendingMessage,
@ -16,6 +11,7 @@ import { convertInputToWhatsAppMessages } from './convertInputToWhatsAppMessage'
import { isNotDefined } from '@typebot.io/lib/utils'
import { computeTypingDuration } from '../computeTypingDuration'
import { continueBotFlow } from '../continueBotFlow'
import { InputBlockType } from '@typebot.io/schemas/features/blocks/inputs/constants'
// Media can take some time to be delivered. This make sure we don't send a message before the media is delivered.
const messageAfterMediaTimeout = 5000

View File

@ -1,8 +1,6 @@
import prisma from '@typebot.io/lib/prisma'
import {
ChatReply,
ComparisonOperators,
LogicalOperator,
PublicTypebot,
SessionState,
Settings,
@ -14,6 +12,11 @@ import {
} from '@typebot.io/schemas/features/whatsapp'
import { isNotDefined } from '@typebot.io/lib/utils'
import { startSession } from '../startSession'
import {
LogicalOperator,
ComparisonOperators,
} from '@typebot.io/schemas/features/blocks/logic/condition/constants'
import { VisitedEdge } from '@typebot.io/prisma'
type Props = {
incomingMessage?: string
@ -30,6 +33,7 @@ export const startWhatsAppSession = async ({
}: Props): Promise<
| (ChatReply & {
newSessionState: SessionState
visitedEdges: VisitedEdge[]
})
| { error: string }
> => {