import { AnswerInSessionState, Block, ContinueChatResponse, Group, InputBlock, Message, SessionState, SetVariableHistoryItem, Variable, } from '@typebot.io/schemas' import { byId, isDefined } from '@typebot.io/lib' import { isInputBlock } from '@typebot.io/schemas/helpers' import { executeGroup, parseInput } from './executeGroup' import { getNextGroup } from './getNextGroup' import { formatEmail } from './blocks/inputs/email/formatEmail' import { formatPhoneNumber } from './blocks/inputs/phone/formatPhoneNumber' import { resumeWebhookExecution } from './blocks/integrations/webhook/resumeWebhookExecution' import { saveAnswer } from './queries/saveAnswer' import { parseButtonsReply } from './blocks/inputs/buttons/parseButtonsReply' import { ParsedReply, Reply } from './types' import { validateNumber } from './blocks/inputs/number/validateNumber' import { parseDateReply } from './blocks/inputs/date/parseDateReply' import { validateRatingReply } from './blocks/inputs/rating/validateRatingReply' import { parsePictureChoicesReply } from './blocks/inputs/pictureChoice/parsePictureChoicesReply' import { parseVariables } from '@typebot.io/variables/parseVariables' import { updateVariablesInSession } from '@typebot.io/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/schemas/helpers' import { ForgedBlock } from '@typebot.io/forge-repository/types' import { forgedBlocks } from '@typebot.io/forge-repository/definitions' import { resumeChatCompletion } from './blocks/integrations/legacy/openai/resumeChatCompletion' import { env } from '@typebot.io/env' import { isURL } from '@typebot.io/lib/validators/isURL' import { isForgedBlockType } from '@typebot.io/schemas/features/blocks/forged/helpers' import { resetSessionState } from './resetSessionState' type Params = { version: 1 | 2 state: SessionState startTime?: number textBubbleContentFormat: 'richText' | 'markdown' } export const continueBotFlow = async ( reply: Reply, { state, version, startTime, textBubbleContentFormat }: Params ): Promise< ContinueChatResponse & { newSessionState: SessionState visitedEdges: VisitedEdge[] setVariableHistory: SetVariableHistoryItem[] } > => { if (!state.currentBlockId) return startBotFlow({ state: resetSessionState(state), version, textBubbleContentFormat, }) const { block, group, blockIndex } = getBlockById( state.currentBlockId, state.typebotsQueue[0].typebot.groups ) if (!block) throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Group / block not found', }) const nonInputProcessResult = await processNonInputBlock({ block, state, reply, }) let newSessionState = nonInputProcessResult.newSessionState const { setVariableHistory, firstBubbleWasStreamed } = nonInputProcessResult let formattedReply: string | undefined if (isInputBlock(block)) { const parsedReplyResult = await parseReply(newSessionState)(reply, block) if (parsedReplyResult.status === 'fail') return { ...(await parseRetryMessage(newSessionState)( block, textBubbleContentFormat )), newSessionState, visitedEdges: [], setVariableHistory: [], } formattedReply = 'reply' in parsedReplyResult && reply?.type === 'text' ? parsedReplyResult.reply : undefined newSessionState = await processAndSaveAnswer( state, block )( isDefined(formattedReply) ? { ...reply, type: 'text', text: formattedReply } : reply ) } const groupHasMoreBlocks = blockIndex < group.blocks.length - 1 const { edgeId: nextEdgeId, isOffDefaultPath } = getOutgoingEdgeId( newSessionState )(block, formattedReply) const lastMessageNewFormat = reply?.type === 'text' && formattedReply !== reply?.text ? formattedReply : undefined if (groupHasMoreBlocks && !nextEdgeId) { const chatReply = await executeGroup( { ...group, blocks: group.blocks.slice(blockIndex + 1), } as Group, { version, state: newSessionState, visitedEdges: [], setVariableHistory, firstBubbleWasStreamed, startTime, textBubbleContentFormat, } ) return { ...chatReply, lastMessageNewFormat, } } if (!nextEdgeId && state.typebotsQueue.length === 1) return { messages: [], newSessionState, lastMessageNewFormat, visitedEdges: [], setVariableHistory, } const nextGroup = await getNextGroup({ state: newSessionState, edgeId: nextEdgeId, isOffDefaultPath, }) newSessionState = nextGroup.newSessionState if (!nextGroup.group) return { messages: [], newSessionState, lastMessageNewFormat, visitedEdges: nextGroup.visitedEdge ? [nextGroup.visitedEdge] : [], setVariableHistory, } const chatReply = await executeGroup(nextGroup.group, { version, state: newSessionState, firstBubbleWasStreamed, visitedEdges: nextGroup.visitedEdge ? [nextGroup.visitedEdge] : [], setVariableHistory, startTime, textBubbleContentFormat, }) return { ...chatReply, lastMessageNewFormat, } } const processNonInputBlock = async ({ block, state, reply, }: { block: Block state: SessionState reply: Reply }) => { if (reply?.type !== 'text') return { newSessionState: state, setVariableHistory: [], firstBubbleWasStreamed: false, } const setVariableHistory: SetVariableHistoryItem[] = [] let variableToUpdate: Variable | undefined let newSessionState = state let firstBubbleWasStreamed = false if (block.type === LogicBlockType.SET_VARIABLE) { const existingVariable = state.typebotsQueue[0].typebot.variables.find( byId(block.options?.variableId) ) if (existingVariable && reply) { variableToUpdate = { ...existingVariable, } } } // Legacy else if ( block.type === IntegrationBlockType.OPEN_AI && block.options?.task === 'Create chat completion' ) { firstBubbleWasStreamed = true if (reply) { const result = await resumeChatCompletion(state, { options: block.options, outgoingEdgeId: block.outgoingEdgeId, })(reply.text) newSessionState = result.newSessionState } } else if (reply && block.type === IntegrationBlockType.WEBHOOK) { const result = resumeWebhookExecution({ state, block, response: JSON.parse(reply.text), }) if (result.newSessionState) newSessionState = result.newSessionState } else if (isForgedBlockType(block.type)) { if (reply) { const options = (block as ForgedBlock).options const action = forgedBlocks[block.type].actions.find( (a) => a.name === options?.action ) if (action) { if (action.run?.stream?.getStreamVariableId) { firstBubbleWasStreamed = true variableToUpdate = state.typebotsQueue[0].typebot.variables.find( (v) => v.id === action?.run?.stream?.getStreamVariableId(options) ) } if ( action.run?.web?.displayEmbedBubble?.waitForEvent?.getSaveVariableId ) { variableToUpdate = state.typebotsQueue[0].typebot.variables.find( (v) => v.id === action?.run?.web?.displayEmbedBubble?.waitForEvent?.getSaveVariableId?.( options ) ) } } } } else if ( block.type === BubbleBlockType.EMBED && block.content?.waitForEvent?.saveDataInVariableId ) { variableToUpdate = state.typebotsQueue[0].typebot.variables.find( (v) => v.id === block.content?.waitForEvent?.saveDataInVariableId ) } if (variableToUpdate) { const { newSetVariableHistory, updatedState } = updateVariablesInSession({ state: newSessionState, currentBlockId: block.id, newVariables: [ { ...variableToUpdate, value: reply?.text ? safeJsonParse(reply?.text) : undefined, }, ], }) newSessionState = updatedState setVariableHistory.push(...newSetVariableHistory) } return { newSessionState, setVariableHistory, firstBubbleWasStreamed, } } const processAndSaveAnswer = (state: SessionState, block: InputBlock) => async (reply: Message | undefined): Promise => { if (!reply) return state return saveAnswerInDb(state, block)(reply) } const saveVariablesValueIfAny = (state: SessionState, block: InputBlock) => (reply: Message): SessionState => { let newSessionState = saveAttachmentsVarIfAny({ block, reply, state }) newSessionState = saveAudioClipVarIfAny({ block, reply, state: newSessionState, }) return saveInputVarIfAny({ block, reply, state: newSessionState }) } const saveAttachmentsVarIfAny = ({ block, reply, state, }: { block: InputBlock reply: Message state: SessionState }): SessionState => { if ( reply.type !== 'text' || block.type !== InputBlockType.TEXT || !block.options?.attachments?.isEnabled || !block.options?.attachments?.saveVariableId || !reply.attachedFileUrls || reply.attachedFileUrls.length === 0 ) return state const variable = state.typebotsQueue[0].typebot.variables.find( (variable) => variable.id === block.options?.attachments?.saveVariableId ) if (!variable) return state const { updatedState } = updateVariablesInSession({ newVariables: [ { id: variable.id, name: variable.name, value: Array.isArray(variable.value) ? variable.value.concat(reply.attachedFileUrls) : reply.attachedFileUrls.length === 1 ? reply.attachedFileUrls[0] : reply.attachedFileUrls, }, ], currentBlockId: undefined, state, }) return updatedState } const saveAudioClipVarIfAny = ({ block, reply, state, }: { block: InputBlock reply: Message state: SessionState }): SessionState => { if ( reply.type !== 'audio' || block.type !== InputBlockType.TEXT || !block.options?.audioClip?.isEnabled || !block.options?.audioClip?.saveVariableId ) return state const variable = state.typebotsQueue[0].typebot.variables.find( (variable) => variable.id === block.options?.audioClip?.saveVariableId ) if (!variable) return state const { updatedState } = updateVariablesInSession({ newVariables: [ { id: variable.id, name: variable.name, value: reply.url, }, ], currentBlockId: undefined, state, }) return updatedState } const saveInputVarIfAny = ({ block, reply, state, }: { block: InputBlock reply: Message state: SessionState }): SessionState => { if (reply.type !== 'text' || !block.options?.variableId) return state const foundVariable = state.typebotsQueue[0].typebot.variables.find( (variable) => variable.id === block.options?.variableId ) if (!foundVariable) return state const { updatedState } = updateVariablesInSession({ newVariables: [ { ...foundVariable, value: Array.isArray(foundVariable.value) && reply.text ? foundVariable.value.concat(reply.text) : reply.text, }, ], currentBlockId: undefined, state, }) return updatedState } const parseRetryMessage = (state: SessionState) => async ( block: InputBlock, textBubbleContentFormat: 'richText' | 'markdown' ): Promise> => { const retryMessage = block.options && 'retryMessageContent' in block.options && block.options.retryMessageContent ? parseVariables(state.typebotsQueue[0].typebot.variables)( block.options.retryMessageContent ) : parseDefaultRetryMessage(block) return { messages: [ { id: block.id, type: BubbleBlockType.TEXT, content: textBubbleContentFormat === 'richText' ? { type: 'richText', richText: [{ type: 'p', children: [{ text: retryMessage }] }], } : { type: 'markdown', markdown: retryMessage, }, }, ], input: await parseInput(state)(block), } } const parseDefaultRetryMessage = (block: InputBlock): string => { switch (block.type) { case InputBlockType.EMAIL: return defaultEmailInputOptions.retryMessageContent case InputBlockType.PAYMENT: return defaultPaymentInputOptions.retryMessageContent default: return 'Invalid message. Please, try again.' } } const saveAnswerInDb = (state: SessionState, block: InputBlock) => async (reply: Message): Promise => { let newSessionState = state const replyContent = reply.type === 'audio' ? reply.url : reply.text const attachedFileUrls = reply.type === 'text' ? reply.attachedFileUrls : undefined await saveAnswer({ answer: { blockId: block.id, content: replyContent, attachedFileUrls, }, state, }) newSessionState = { ...saveVariablesValueIfAny(newSessionState, block)(reply), previewMetadata: state.typebotsQueue[0].resultId ? newSessionState.previewMetadata : { ...newSessionState.previewMetadata, answers: (newSessionState.previewMetadata?.answers ?? []).concat({ blockId: block.id, content: replyContent, attachedFileUrls, }), }, } const key = block.options?.variableId ? newSessionState.typebotsQueue[0].typebot.variables.find( (variable) => variable.id === block.options?.variableId )?.name : parseGroupKey(block.id, { state: newSessionState }) return setNewAnswerInState(newSessionState)({ key: key ?? block.id, value: (attachedFileUrls ?? []).length > 0 ? `${attachedFileUrls!.join(', ')}\n\n${replyContent}` : replyContent, }) } const parseGroupKey = (blockId: string, { state }: { state: SessionState }) => { const group = state.typebotsQueue[0].typebot.groups.find((group) => group.blocks.find((b) => b.id === blockId) ) if (!group) return const inputBlockNumber = group.blocks .filter(isInputBlock) .findIndex((b) => b.id === blockId) return inputBlockNumber > 0 ? `${group.title} (${inputBlockNumber})` : group?.title } const setNewAnswerInState = (state: SessionState) => (newAnswer: AnswerInSessionState) => { const answers = state.typebotsQueue[0].answers const newAnswers = answers .filter((answer) => answer.key !== newAnswer.key) .concat(newAnswer) return { ...state, progressMetadata: state.progressMetadata ? { totalAnswers: state.progressMetadata.totalAnswers + 1 } : undefined, typebotsQueue: state.typebotsQueue.map((typebot, index) => index === 0 ? { ...typebot, answers: newAnswers, } : typebot ), } satisfies SessionState } const getOutgoingEdgeId = (state: Pick) => ( block: Block, reply: string | undefined ): { edgeId: string | undefined; isOffDefaultPath: boolean } => { const variables = state.typebotsQueue[0].typebot.variables if ( block.type === InputBlockType.CHOICE && !( block.options?.isMultipleChoice ?? defaultChoiceInputOptions.isMultipleChoice ) && reply ) { const matchedItem = block.items.find( (item) => parseVariables(variables)(item.content).normalize() === reply.normalize() ) if (matchedItem?.outgoingEdgeId) return { edgeId: matchedItem.outgoingEdgeId, isOffDefaultPath: true } } if ( block.type === InputBlockType.PICTURE_CHOICE && !( block.options?.isMultipleChoice ?? defaultPictureChoiceOptions.isMultipleChoice ) && reply ) { const matchedItem = block.items.find( (item) => parseVariables(variables)(item.title).normalize() === reply.normalize() ) if (matchedItem?.outgoingEdgeId) return { edgeId: matchedItem.outgoingEdgeId, isOffDefaultPath: true } } return { edgeId: block.outgoingEdgeId, isOffDefaultPath: false } } const parseReply = (state: SessionState) => async (reply: Reply, block: InputBlock): Promise => { switch (block.type) { case InputBlockType.EMAIL: { if (!reply || reply.type !== 'text') return { status: 'fail' } const formattedEmail = formatEmail(reply.text) if (!formattedEmail) return { status: 'fail' } return { status: 'success', reply: formattedEmail } } case InputBlockType.PHONE: { if (!reply || reply.type !== 'text') return { status: 'fail' } const formattedPhone = formatPhoneNumber( reply.text, block.options?.defaultCountryCode ) if (!formattedPhone) return { status: 'fail' } return { status: 'success', reply: formattedPhone } } case InputBlockType.URL: { if (!reply || reply.type !== 'text') return { status: 'fail' } const isValid = isURL(reply.text, { require_protocol: false }) if (!isValid) return { status: 'fail' } return { status: 'success', reply: reply.text } } case InputBlockType.CHOICE: { if (!reply || reply.type !== 'text') return { status: 'fail' } return parseButtonsReply(state)(reply.text, block) } case InputBlockType.NUMBER: { if (!reply || reply.type !== 'text') return { status: 'fail' } const isValid = validateNumber(reply.text, { options: block.options, variables: state.typebotsQueue[0].typebot.variables, }) if (!isValid) return { status: 'fail' } return { status: 'success', reply: parseNumber(reply.text) } } case InputBlockType.DATE: { if (!reply || reply.type !== 'text') return { status: 'fail' } return parseDateReply(reply.text, block) } case InputBlockType.FILE: { if (!reply) return block.options?.isRequired ?? defaultFileInputOptions.isRequired ? { status: 'fail' } : { status: 'skip' } const replyValue = reply.type === 'audio' ? reply.url : reply.text const urls = replyValue.split(', ') const status = urls.some((url) => isURL(url, { require_tld: env.S3_ENDPOINT !== 'localhost' }) ) ? 'success' : 'fail' if (!block.options?.isMultipleAllowed && urls.length > 1) return { status, reply: replyValue.split(',')[0] } return { status, reply: replyValue } } case InputBlockType.PAYMENT: { if (!reply || reply.type !== 'text') return { status: 'fail' } if (reply.text === 'fail') return { status: 'fail' } return { status: 'success', reply: reply.text } } case InputBlockType.RATING: { if (!reply || reply.type !== 'text') return { status: 'fail' } const isValid = validateRatingReply(reply.text, block) if (!isValid) return { status: 'fail' } return { status: 'success', reply: reply.text } } case InputBlockType.PICTURE_CHOICE: { if (!reply || reply.type !== 'text') return { status: 'fail' } return parsePictureChoicesReply(state)(reply.text, block) } case InputBlockType.TEXT: { if (!reply) return { status: 'fail' } return { status: 'success', reply: reply.type === 'audio' ? reply.url : reply.text, } } } } export const safeJsonParse = (value: string): unknown => { try { return JSON.parse(value) } catch { return value } }