2
0

♻️ Export bot-engine code into its own package

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

View File

@ -0,0 +1,26 @@
import { createId } from '@paralleldrive/cuid2'
import { SessionState, Edge } from '@typebot.io/schemas'
export const addEdgeToTypebot = (
state: SessionState,
edge: Edge
): SessionState => ({
...state,
typebotsQueue: state.typebotsQueue.map((typebot, index) =>
index === 0
? {
...typebot,
typebot: {
...typebot.typebot,
edges: [...typebot.typebot.edges, edge],
},
}
: typebot
),
})
export const createPortalEdge = ({ to }: Pick<Edge, 'to'>) => ({
id: createId(),
from: { blockId: '', groupId: '' },
to,
})

View File

@ -0,0 +1,17 @@
import { ChoiceInputBlock, Variable } from '@typebot.io/schemas'
import { executeCondition } from '../../logic/condition/executeCondition'
export const filterChoiceItems =
(variables: Variable[]) =>
(block: ChoiceInputBlock): ChoiceInputBlock => {
const filteredItems = block.items.filter((item) => {
if (item.displayCondition?.isEnabled && item.displayCondition?.condition)
return executeCondition(variables)(item.displayCondition.condition)
return true
})
return {
...block,
items: filteredItems,
}
}

View File

@ -0,0 +1,50 @@
import {
SessionState,
VariableWithValue,
ChoiceInputBlock,
ItemType,
} from '@typebot.io/schemas'
import { isDefined } from '@typebot.io/lib'
import { filterChoiceItems } from './filterChoiceItems'
import { deepParseVariables } from '../../../variables/deepParseVariables'
import { transformStringVariablesToList } from '../../../variables/transformVariablesToList'
import { updateVariablesInSession } from '../../../variables/updateVariablesInSession'
export const injectVariableValuesInButtonsInputBlock =
(state: SessionState) =>
(block: ChoiceInputBlock): ChoiceInputBlock => {
const { variables } = state.typebotsQueue[0].typebot
if (block.options.dynamicVariableId) {
const variable = variables.find(
(variable) =>
variable.id === block.options.dynamicVariableId &&
isDefined(variable.value)
) as VariableWithValue | undefined
if (!variable) return block
const value = getVariableValue(state)(variable)
return {
...block,
items: value.filter(isDefined).map((item, idx) => ({
id: idx.toString(),
type: ItemType.BUTTON,
blockId: block.id,
content: item,
})),
}
}
return deepParseVariables(variables)(filterChoiceItems(variables)(block))
}
const getVariableValue =
(state: SessionState) =>
(variable: VariableWithValue): (string | null)[] => {
if (!Array.isArray(variable.value)) {
const { variables } = state.typebotsQueue[0].typebot
const [transformedVariable] = transformStringVariablesToList(variables)([
variable.id,
])
updateVariablesInSession(state)([transformedVariable])
return transformedVariable.value as string[]
}
return variable.value
}

View File

@ -0,0 +1,87 @@
import { ChoiceInputBlock, SessionState } from '@typebot.io/schemas'
import { injectVariableValuesInButtonsInputBlock } from './injectVariableValuesInButtonsInputBlock'
import { ParsedReply } from '../../../types'
export const parseButtonsReply =
(state: SessionState) =>
(inputValue: string, block: ChoiceInputBlock): ParsedReply => {
const displayedItems =
injectVariableValuesInButtonsInputBlock(state)(block).items
if (block.options.isMultipleChoice) {
const longestItemsFirst = [...displayedItems].sort(
(a, b) => (b.content?.length ?? 0) - (a.content?.length ?? 0)
)
const matchedItemsByContent = longestItemsFirst.reduce<{
strippedInput: string
matchedItemIds: string[]
}>(
(acc, item) => {
if (
item.content &&
acc.strippedInput.toLowerCase().includes(item.content.toLowerCase())
)
return {
strippedInput: acc.strippedInput.replace(item.content ?? '', ''),
matchedItemIds: [...acc.matchedItemIds, item.id],
}
return acc
},
{
strippedInput: inputValue.trim(),
matchedItemIds: [],
}
)
const remainingItems = displayedItems.filter(
(item) => !matchedItemsByContent.matchedItemIds.includes(item.id)
)
const matchedItemsByIndex = remainingItems.reduce<{
strippedInput: string
matchedItemIds: string[]
}>(
(acc, item, idx) => {
if (acc.strippedInput.includes(`${idx + 1}`))
return {
strippedInput: acc.strippedInput.replace(`${idx + 1}`, ''),
matchedItemIds: [...acc.matchedItemIds, item.id],
}
return acc
},
{
strippedInput: matchedItemsByContent.strippedInput,
matchedItemIds: [],
}
)
const matchedItems = displayedItems.filter((item) =>
[
...matchedItemsByContent.matchedItemIds,
...matchedItemsByIndex.matchedItemIds,
].includes(item.id)
)
if (matchedItems.length === 0) return { status: 'fail' }
return {
status: 'success',
reply: matchedItems.map((item) => item.content).join(', '),
}
}
if (state.whatsApp) {
const matchedItem = displayedItems.find((item) => item.id === inputValue)
if (!matchedItem) return { status: 'fail' }
return {
status: 'success',
reply: matchedItem.content ?? '',
}
}
const longestItemsFirst = [...displayedItems].sort(
(a, b) => (b.content?.length ?? 0) - (a.content?.length ?? 0)
)
const matchedItem = longestItemsFirst.find(
(item) =>
item.content &&
inputValue.toLowerCase().trim() === item.content.toLowerCase().trim()
)
if (!matchedItem) return { status: 'fail' }
return {
status: 'success',
reply: matchedItem.content ?? '',
}
}

View File

@ -0,0 +1,48 @@
import { getPrefilledInputValue } from '../../../getPrefilledValue'
import {
DateInputBlock,
DateInputOptions,
SessionState,
Variable,
} from '@typebot.io/schemas'
import { deepParseVariables } from '../../../variables/deepParseVariables'
import { parseVariables } from '../../../variables/parseVariables'
export const parseDateInput =
(state: SessionState) => (block: DateInputBlock) => {
return {
...block,
options: {
...deepParseVariables(state.typebotsQueue[0].typebot.variables)(
block.options
),
min: parseDateLimit(
block.options.min,
block.options.hasTime,
state.typebotsQueue[0].typebot.variables
),
max: parseDateLimit(
block.options.max,
block.options.hasTime,
state.typebotsQueue[0].typebot.variables
),
},
prefilledValue: getPrefilledInputValue(
state.typebotsQueue[0].typebot.variables
)(block),
}
}
const parseDateLimit = (
limit: DateInputOptions['min'] | DateInputOptions['max'],
hasTime: DateInputOptions['hasTime'],
variables: Variable[]
) => {
if (!limit) return
const parsedLimit = parseVariables(variables)(limit)
const dateIsoNoSecondsRegex = /\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d/
const matchDateTime = parsedLimit.match(dateIsoNoSecondsRegex)
if (matchDateTime)
return hasTime ? matchDateTime[0] : matchDateTime[0].slice(0, 10)
return parsedLimit
}

View File

@ -0,0 +1,51 @@
import { ParsedReply } from '../../../types'
import { DateInputBlock } from '@typebot.io/schemas'
import { parse as chronoParse } from 'chrono-node'
import { format } from 'date-fns'
export const parseDateReply = (
reply: string,
block: DateInputBlock
): ParsedReply => {
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')
const detectedStartDate = parseDateWithNeutralTimezone(
parsedDate[0].start.date()
)
const startDate = format(detectedStartDate, formatString)
const detectedEndDate = parsedDate[0].end?.date()
? parseDateWithNeutralTimezone(parsedDate[0].end?.date())
: undefined
const endDate = detectedEndDate
? format(detectedEndDate, formatString)
: undefined
if (block.options.isRange && !endDate) return { status: 'fail' }
if (
block.options.max &&
(detectedStartDate > new Date(block.options.max) ||
(detectedEndDate && detectedEndDate > new Date(block.options.max)))
)
return { status: 'fail' }
if (
block.options.min &&
(detectedStartDate < new Date(block.options.min) ||
(detectedEndDate && detectedEndDate < new Date(block.options.min)))
)
return { status: 'fail' }
return {
status: 'success',
reply: block.options.isRange ? `${startDate} to ${endDate}` : startDate,
}
}
const parseDateWithNeutralTimezone = (date: Date) =>
new Date(date.valueOf() + date.getTimezoneOffset() * 60 * 1000)

View File

@ -0,0 +1,4 @@
const emailRegex =
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
export const validateEmail = (email: string) => emailRegex.test(email)

View File

@ -0,0 +1 @@
export const validateNumber = (inputValue: string) => !isNaN(Number(inputValue))

View File

@ -0,0 +1,124 @@
import { TRPCError } from '@trpc/server'
import {
PaymentInputOptions,
PaymentInputRuntimeOptions,
SessionState,
StripeCredentials,
} from '@typebot.io/schemas'
import Stripe from 'stripe'
import { decrypt } from '@typebot.io/lib/api/encryption'
import { parseVariables } from '../../../variables/parseVariables'
import prisma from '@typebot.io/lib/prisma'
export const computePaymentInputRuntimeOptions =
(state: SessionState) => (options: PaymentInputOptions) =>
createStripePaymentIntent(state)(options)
const createStripePaymentIntent =
(state: SessionState) =>
async (options: PaymentInputOptions): Promise<PaymentInputRuntimeOptions> => {
const {
resultId,
typebot: { variables },
} = state.typebotsQueue[0]
const isPreview = !resultId
if (!options.credentialsId)
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Missing credentialsId',
})
const stripeKeys = await getStripeInfo(options.credentialsId)
if (!stripeKeys)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Credentials not found',
})
const stripe = new Stripe(
isPreview && stripeKeys?.test?.secretKey
? stripeKeys.test.secretKey
: stripeKeys.live.secretKey,
{ apiVersion: '2022-11-15' }
)
const amount = Math.round(
Number(parseVariables(variables)(options.amount)) *
(isZeroDecimalCurrency(options.currency) ? 1 : 100)
)
if (isNaN(amount))
throw new TRPCError({
code: 'BAD_REQUEST',
message:
'Could not parse amount, make sure your block is configured correctly',
})
// Create a PaymentIntent with the order amount and currency
const receiptEmail = parseVariables(variables)(
options.additionalInformation?.email
)
const paymentIntent = await stripe.paymentIntents.create({
amount,
currency: options.currency,
receipt_email: receiptEmail === '' ? undefined : receiptEmail,
description: options.additionalInformation?.description,
automatic_payment_methods: {
enabled: true,
},
})
if (!paymentIntent.client_secret)
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Could not create payment intent',
})
const priceFormatter = new Intl.NumberFormat(
options.currency === 'EUR' ? 'fr-FR' : undefined,
{
style: 'currency',
currency: options.currency,
}
)
return {
paymentIntentSecret: paymentIntent.client_secret,
publicKey:
isPreview && stripeKeys.test?.publicKey
? stripeKeys.test.publicKey
: stripeKeys.live.publicKey,
amountLabel: priceFormatter.format(
amount / (isZeroDecimalCurrency(options.currency) ? 1 : 100)
),
}
}
const getStripeInfo = async (
credentialsId: string
): Promise<StripeCredentials['data'] | undefined> => {
const credentials = await prisma.credentials.findUnique({
where: { id: credentialsId },
})
if (!credentials) return
return (await decrypt(
credentials.data,
credentials.iv
)) as StripeCredentials['data']
}
// https://stripe.com/docs/currencies#zero-decimal
const isZeroDecimalCurrency = (currency: string) =>
[
'BIF',
'CLP',
'DJF',
'GNF',
'JPY',
'KMF',
'KRW',
'MGA',
'PYG',
'RWF',
'UGX',
'VND',
'VUV',
'XAF',
'XOF',
'XPF',
].includes(currency)

View File

@ -0,0 +1,16 @@
import {
CountryCode,
findPhoneNumbersInText,
isSupportedCountry,
} from 'libphonenumber-js'
export const formatPhoneNumber = (
phoneNumber: string,
defaultCountryCode?: string
) =>
findPhoneNumbersInText(
phoneNumber,
defaultCountryCode && isSupportedCountry(defaultCountryCode)
? (defaultCountryCode as CountryCode)
: undefined
).at(0)?.number.number

View File

@ -0,0 +1,17 @@
import { PictureChoiceBlock, Variable } from '@typebot.io/schemas'
import { executeCondition } from '../../logic/condition/executeCondition'
export const filterPictureChoiceItems =
(variables: Variable[]) =>
(block: PictureChoiceBlock): PictureChoiceBlock => {
const filteredItems = block.items.filter((item) => {
if (item.displayCondition?.isEnabled && item.displayCondition?.condition)
return executeCondition(variables)(item.displayCondition.condition)
return true
})
return {
...block,
items: filteredItems,
}
}

View File

@ -0,0 +1,58 @@
import {
VariableWithValue,
ItemType,
PictureChoiceBlock,
Variable,
} from '@typebot.io/schemas'
import { isDefined } from '@typebot.io/lib'
import { filterPictureChoiceItems } from './filterPictureChoiceItems'
import { deepParseVariables } from '../../../variables/deepParseVariables'
export const injectVariableValuesInPictureChoiceBlock =
(variables: Variable[]) =>
(block: PictureChoiceBlock): PictureChoiceBlock => {
if (
block.options.dynamicItems?.isEnabled &&
block.options.dynamicItems.pictureSrcsVariableId
) {
const pictureSrcsVariable = variables.find(
(variable) =>
variable.id === block.options.dynamicItems?.pictureSrcsVariableId &&
isDefined(variable.value)
) as VariableWithValue | undefined
if (!pictureSrcsVariable || typeof pictureSrcsVariable.value === 'string')
return block
const titlesVariable = block.options.dynamicItems.titlesVariableId
? (variables.find(
(variable) =>
variable.id === block.options.dynamicItems?.titlesVariableId &&
isDefined(variable.value)
) as VariableWithValue | undefined)
: undefined
const descriptionsVariable = block.options.dynamicItems
.descriptionsVariableId
? (variables.find(
(variable) =>
variable.id ===
block.options.dynamicItems?.descriptionsVariableId &&
isDefined(variable.value)
) as VariableWithValue | undefined)
: undefined
return {
...block,
items: pictureSrcsVariable.value
.filter(isDefined)
.map((pictureSrc, idx) => ({
id: idx.toString(),
type: ItemType.PICTURE_CHOICE,
blockId: block.id,
pictureSrc,
title: titlesVariable?.value?.[idx] ?? '',
description: descriptionsVariable?.value?.[idx] ?? '',
})),
}
}
return deepParseVariables(variables)(
filterPictureChoiceItems(variables)(block)
)
}

View File

@ -0,0 +1,95 @@
import { PictureChoiceBlock, SessionState } from '@typebot.io/schemas'
import { ParsedReply } from '../../../types'
import { injectVariableValuesInPictureChoiceBlock } from './injectVariableValuesInPictureChoiceBlock'
export const parsePictureChoicesReply =
(state: SessionState) =>
(inputValue: string, block: PictureChoiceBlock): ParsedReply => {
const displayedItems = injectVariableValuesInPictureChoiceBlock(
state.typebotsQueue[0].typebot.variables
)(block).items
if (block.options.isMultipleChoice) {
const longestItemsFirst = [...displayedItems].sort(
(a, b) => (b.title?.length ?? 0) - (a.title?.length ?? 0)
)
const matchedItemsByContent = longestItemsFirst.reduce<{
strippedInput: string
matchedItemIds: string[]
}>(
(acc, item) => {
if (
item.title &&
acc.strippedInput.toLowerCase().includes(item.title.toLowerCase())
)
return {
strippedInput: acc.strippedInput.replace(item.title ?? '', ''),
matchedItemIds: [...acc.matchedItemIds, item.id],
}
return acc
},
{
strippedInput: inputValue.trim(),
matchedItemIds: [],
}
)
const remainingItems = displayedItems.filter(
(item) => !matchedItemsByContent.matchedItemIds.includes(item.id)
)
const matchedItemsByIndex = remainingItems.reduce<{
strippedInput: string
matchedItemIds: string[]
}>(
(acc, item, idx) => {
if (acc.strippedInput.includes(`${idx + 1}`))
return {
strippedInput: acc.strippedInput.replace(`${idx + 1}`, ''),
matchedItemIds: [...acc.matchedItemIds, item.id],
}
return acc
},
{
strippedInput: matchedItemsByContent.strippedInput,
matchedItemIds: [],
}
)
const matchedItems = displayedItems.filter((item) =>
[
...matchedItemsByContent.matchedItemIds,
...matchedItemsByIndex.matchedItemIds,
].includes(item.id)
)
if (matchedItems.length === 0) return { status: 'fail' }
return {
status: 'success',
reply: matchedItems
.map((item) => item.title ?? item.pictureSrc ?? '')
.join(', '),
}
}
if (state.whatsApp) {
const matchedItem = displayedItems.find((item) => item.id === inputValue)
if (!matchedItem) return { status: 'fail' }
return {
status: 'success',
reply: matchedItem.title ?? matchedItem.pictureSrc ?? '',
}
}
const longestItemsFirst = [...displayedItems].sort(
(a, b) => (b.title?.length ?? 0) - (a.title?.length ?? 0)
)
const matchedItem = longestItemsFirst.find(
(item) =>
item.title &&
item.title
.toLowerCase()
.trim()
.includes(inputValue.toLowerCase().trim())
)
if (!matchedItem) return { status: 'fail' }
return {
status: 'success',
reply: matchedItem.title ?? matchedItem.pictureSrc ?? '',
}
}

View File

@ -0,0 +1,4 @@
import { RatingInputBlock } from '@typebot.io/schemas'
export const validateRatingReply = (reply: string, block: RatingInputBlock) =>
Number(reply) <= block.options.length

View File

@ -0,0 +1,4 @@
const urlRegex =
/^(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})$/
export const validateUrl = (url: string) => urlRegex.test(url)

View File

@ -0,0 +1,119 @@
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 { extractVariablesFromText } from '../../../variables/extractVariablesFromText'
import { parseGuessedValueType } from '../../../variables/parseGuessedValueType'
import { parseVariables } from '../../../variables/parseVariables'
const parseSetUserCode = (user: ChatwootOptions['user'], resultId: string) =>
user?.email || user?.id
? `
window.$chatwoot.setUser(${user?.id ?? `"${resultId}"`}, {
email: ${user?.email ? user.email : 'undefined'},
name: ${user?.name ? user.name : 'undefined'},
avatar_url: ${user?.avatarUrl ? user.avatarUrl : 'undefined'},
phone_number: ${user?.phoneNumber ? user.phoneNumber : 'undefined'},
});`
: ''
const parseChatwootOpenCode = ({
baseUrl,
websiteToken,
user,
resultId,
typebotId,
}: ChatwootOptions & { typebotId: string; resultId: string }) => {
const openChatwoot = `${parseSetUserCode(user, resultId)}
window.$chatwoot.setCustomAttributes({
typebot_result_url: "${
env.NEXTAUTH_URL
}/typebots/${typebotId}/results?id=${resultId}",
});
window.$chatwoot.toggle("open");
`
return `
window.addEventListener("chatwoot:error", function (error) {
console.log(error);
});
if (window.$chatwoot) {${openChatwoot}}
else {
(function (d, t) {
var BASE_URL = "${baseUrl}";
var g = d.createElement(t),
s = d.getElementsByTagName(t)[0];
g.src = BASE_URL + "/packs/js/sdk.js";
g.defer = true;
g.async = true;
s.parentNode.insertBefore(g, s);
g.onload = function () {
window.chatwootSDK.run({
websiteToken: "${websiteToken}",
baseUrl: BASE_URL,
});
window.addEventListener("chatwoot:ready", function () {${openChatwoot}});
};
})(document, "script");
}`
}
const chatwootCloseCode = `
if (window.$chatwoot) {
window.$chatwoot.toggle("close");
window.$chatwoot.toggleBubbleVisibility("hide");
}
`
export const executeChatwootBlock = (
state: SessionState,
block: ChatwootBlock
): ExecuteIntegrationResponse => {
if (state.whatsApp) return { outgoingEdgeId: block.outgoingEdgeId }
const { typebot, resultId } = state.typebotsQueue[0]
const chatwootCode =
block.options.task === 'Close widget'
? chatwootCloseCode
: isDefined(resultId)
? parseChatwootOpenCode({
...block.options,
typebotId: typebot.id,
resultId,
})
: ''
return {
outgoingEdgeId: block.outgoingEdgeId,
clientSideActions: [
{
chatwoot: {
scriptToExecute: {
content: parseVariables(typebot.variables, { fieldToParse: 'id' })(
chatwootCode
),
args: extractVariablesFromText(typebot.variables)(chatwootCode).map(
(variable) => ({
id: variable.id,
value: parseGuessedValueType(variable.value),
})
),
},
},
},
],
logs:
chatwootCode === ''
? [
{
status: 'info',
description: 'Chatwoot block is not supported in preview',
details: null,
},
]
: undefined,
}
}

View File

@ -0,0 +1,24 @@
import { ExecuteIntegrationResponse } from '../../../types'
import { GoogleAnalyticsBlock, SessionState } from '@typebot.io/schemas'
import { deepParseVariables } from '../../../variables/deepParseVariables'
export const executeGoogleAnalyticsBlock = (
state: SessionState,
block: GoogleAnalyticsBlock
): ExecuteIntegrationResponse => {
const { typebot, resultId } = state.typebotsQueue[0]
if (!resultId || state.whatsApp)
return { outgoingEdgeId: block.outgoingEdgeId }
const googleAnalytics = deepParseVariables(typebot.variables, {
guessCorrectTypes: true,
removeEmptyStrings: true,
})(block.options)
return {
outgoingEdgeId: block.outgoingEdgeId,
clientSideActions: [
{
googleAnalytics,
},
],
}
}

View File

@ -0,0 +1,34 @@
import {
GoogleSheetsBlock,
GoogleSheetsAction,
SessionState,
} from '@typebot.io/schemas'
import { insertRow } from './insertRow'
import { updateRow } from './updateRow'
import { getRow } from './getRow'
import { ExecuteIntegrationResponse } from '../../../types'
export const executeGoogleSheetBlock = async (
state: SessionState,
block: GoogleSheetsBlock
): Promise<ExecuteIntegrationResponse> => {
const action = block.options.action
if (!action) return { outgoingEdgeId: block.outgoingEdgeId }
switch (action) {
case GoogleSheetsAction.INSERT_ROW:
return insertRow(state, {
options: block.options,
outgoingEdgeId: block.outgoingEdgeId,
})
case GoogleSheetsAction.UPDATE_ROW:
return updateRow(state, {
options: block.options,
outgoingEdgeId: block.outgoingEdgeId,
})
case GoogleSheetsAction.GET:
return getRow(state, {
options: block.options,
outgoingEdgeId: block.outgoingEdgeId,
})
}
}

View File

@ -0,0 +1,108 @@
import {
SessionState,
GoogleSheetsGetOptions,
VariableWithValue,
ReplyLog,
} from '@typebot.io/schemas'
import { isNotEmpty, byId } from '@typebot.io/lib'
import { getAuthenticatedGoogleDoc } from './helpers/getAuthenticatedGoogleDoc'
import { ExecuteIntegrationResponse } from '../../../types'
import { matchFilter } from './helpers/matchFilter'
import { deepParseVariables } from '../../../variables/deepParseVariables'
import { updateVariablesInSession } from '../../../variables/updateVariablesInSession'
export const getRow = async (
state: SessionState,
{
outgoingEdgeId,
options,
}: { outgoingEdgeId?: string; options: GoogleSheetsGetOptions }
): Promise<ExecuteIntegrationResponse> => {
const logs: ReplyLog[] = []
const { variables } = state.typebotsQueue[0].typebot
const { sheetId, cellsToExtract, referenceCell, filter } =
deepParseVariables(variables)(options)
if (!sheetId) return { outgoingEdgeId }
const doc = await getAuthenticatedGoogleDoc({
credentialsId: options.credentialsId,
spreadsheetId: options.spreadsheetId,
})
try {
await doc.loadInfo()
const sheet = doc.sheetsById[Number(sheetId)]
const rows = await sheet.getRows()
const filteredRows = getTotalRows(
options.totalRowsToExtract,
rows.filter((row) =>
referenceCell
? row.get(referenceCell.column as string) === referenceCell.value
: matchFilter(row, filter)
)
)
if (filteredRows.length === 0) {
logs.push({
status: 'info',
description: `Couldn't find any rows matching the filter`,
details: JSON.stringify(filter, null, 2),
})
return { outgoingEdgeId, logs }
}
const extractingColumns = cellsToExtract
.map((cell) => cell.column)
.filter(isNotEmpty)
const selectedRows = filteredRows.map((row) =>
extractingColumns.reduce<{ [key: string]: string }>(
(obj, column) => ({ ...obj, [column]: row.get(column) }),
{}
)
)
if (!selectedRows) return { outgoingEdgeId }
const newVariables = options.cellsToExtract.reduce<VariableWithValue[]>(
(newVariables, cell) => {
const existingVariable = variables.find(byId(cell.variableId))
const value = selectedRows.map((row) => row[cell.column ?? ''])
if (!existingVariable) return newVariables
return [
...newVariables,
{
...existingVariable,
value: value.length === 1 ? value[0] : value,
},
]
},
[]
)
const newSessionState = updateVariablesInSession(state)(newVariables)
return {
outgoingEdgeId,
newSessionState,
}
} catch (err) {
logs.push({
status: 'error',
description: `An error occurred while fetching the spreadsheet data`,
details: err,
})
}
return { outgoingEdgeId, logs }
}
const getTotalRows = <T>(
totalRowsToExtract: GoogleSheetsGetOptions['totalRowsToExtract'],
rows: T[]
): T[] => {
switch (totalRowsToExtract) {
case 'All':
case undefined:
return rows
case 'First':
return rows.slice(0, 1)
case 'Last':
return rows.slice(-1)
case 'Random':
return [rows[Math.floor(Math.random() * rows.length)]]
}
}

View File

@ -0,0 +1,75 @@
import { TRPCError } from '@trpc/server'
import { env } from '@typebot.io/env'
import { decrypt, encrypt } from '@typebot.io/lib/api/encryption'
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'
export const getAuthenticatedGoogleDoc = async ({
credentialsId,
spreadsheetId,
}: {
credentialsId?: string
spreadsheetId?: string
}) => {
if (!credentialsId || !spreadsheetId)
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Missing credentialsId or spreadsheetId',
})
const auth = await getAuthenticatedGoogleClient(credentialsId)
if (!auth)
throw new TRPCError({
code: 'NOT_FOUND',
message: "Couldn't find credentials in database",
})
return new GoogleSpreadsheet(spreadsheetId, auth)
}
const getAuthenticatedGoogleClient = async (
credentialsId: string
): Promise<OAuth2Client | undefined> => {
const credentials = (await prisma.credentials.findFirst({
where: { id: credentialsId },
})) as CredentialsFromDb | undefined
if (!credentials) return
const data = (await decrypt(
credentials.data,
credentials.iv
)) as GoogleSheetsCredentials['data']
const oauth2Client = new OAuth2Client(
env.GOOGLE_CLIENT_ID,
env.GOOGLE_CLIENT_SECRET,
`${env.NEXTAUTH_URL}/api/credentials/google-sheets/callback`
)
oauth2Client.setCredentials(data)
oauth2Client.on('tokens', updateTokens(credentialsId, data))
return oauth2Client
}
const updateTokens =
(
credentialsId: string,
existingCredentials: GoogleSheetsCredentials['data']
) =>
async (credentials: Credentials) => {
if (
isDefined(existingCredentials.id_token) &&
credentials.id_token !== existingCredentials.id_token
)
return
const newCredentials: GoogleSheetsCredentials['data'] = {
...existingCredentials,
expiry_date: credentials.expiry_date,
access_token: credentials.access_token,
}
const { encryptedData, iv } = await encrypt(newCredentials)
await prisma.credentials.updateMany({
where: { id: credentialsId },
data: { data: encryptedData, iv },
})
}

View File

@ -0,0 +1,95 @@
import { isDefined } from '@typebot.io/lib'
import {
GoogleSheetsGetOptions,
LogicalOperator,
ComparisonOperators,
} from '@typebot.io/schemas'
import { GoogleSpreadsheetRow } from 'google-spreadsheet'
export const matchFilter = (
row: GoogleSpreadsheetRow,
filter: GoogleSheetsGetOptions['filter']
) => {
if (!filter) return true
return filter.logicalOperator === LogicalOperator.AND
? filter.comparisons.every(
(comparison) =>
comparison.column &&
matchComparison(
row.get(comparison.column),
comparison.comparisonOperator,
comparison.value
)
)
: filter.comparisons.some(
(comparison) =>
comparison.column &&
matchComparison(
row.get(comparison.column),
comparison.comparisonOperator,
comparison.value
)
)
}
const matchComparison = (
inputValue?: string,
comparisonOperator?: ComparisonOperators,
value?: string
): boolean | undefined => {
if (!comparisonOperator) return false
switch (comparisonOperator) {
case ComparisonOperators.CONTAINS: {
if (!inputValue || !value) return false
return inputValue
.toLowerCase()
.trim()
.normalize()
.includes(value.toLowerCase().trim().normalize())
}
case ComparisonOperators.EQUAL: {
return inputValue?.normalize() === value?.normalize()
}
case ComparisonOperators.NOT_EQUAL: {
return inputValue?.normalize() !== value?.normalize()
}
case ComparisonOperators.GREATER: {
if (!inputValue || !value) return false
return parseFloat(inputValue) > parseFloat(value)
}
case ComparisonOperators.LESS: {
if (!inputValue || !value) return false
return parseFloat(inputValue) < parseFloat(value)
}
case ComparisonOperators.IS_SET: {
return isDefined(inputValue) && inputValue.length > 0
}
case ComparisonOperators.IS_EMPTY: {
return !isDefined(inputValue) || inputValue.length === 0
}
case ComparisonOperators.STARTS_WITH: {
if (!inputValue || !value) return false
return inputValue
.toLowerCase()
.trim()
.normalize()
.startsWith(value.toLowerCase().trim().normalize())
}
case ComparisonOperators.ENDS_WITH: {
if (!inputValue || !value) return false
return inputValue
.toLowerCase()
.trim()
.normalize()
.endsWith(value.toLowerCase().trim().normalize())
}
case ComparisonOperators.NOT_CONTAINS: {
if (!inputValue || !value) return false
return !inputValue
?.toLowerCase()
.trim()
.normalize()
.includes(value.toLowerCase().trim().normalize())
}
}
}

View File

@ -0,0 +1,14 @@
import { Variable, Cell } from '@typebot.io/schemas'
import { parseVariables } from '../../../../variables/parseVariables'
export const parseCellValues =
(variables: Variable[]) =>
(cells: Cell[]): { [key: string]: string } =>
cells.reduce((row, cell) => {
return !cell.column || !cell.value
? row
: {
...row,
[cell.column]: parseVariables(variables)(cell.value),
}
}, {})

View File

@ -0,0 +1,46 @@
import {
SessionState,
GoogleSheetsInsertRowOptions,
ReplyLog,
} from '@typebot.io/schemas'
import { parseCellValues } from './helpers/parseCellValues'
import { getAuthenticatedGoogleDoc } from './helpers/getAuthenticatedGoogleDoc'
import { ExecuteIntegrationResponse } from '../../../types'
export const insertRow = async (
state: SessionState,
{
outgoingEdgeId,
options,
}: { outgoingEdgeId?: string; options: GoogleSheetsInsertRowOptions }
): Promise<ExecuteIntegrationResponse> => {
const { variables } = state.typebotsQueue[0].typebot
if (!options.cellsToInsert || !options.sheetId) return { outgoingEdgeId }
const logs: ReplyLog[] = []
const doc = await getAuthenticatedGoogleDoc({
credentialsId: options.credentialsId,
spreadsheetId: options.spreadsheetId,
})
const parsedValues = parseCellValues(variables)(options.cellsToInsert)
try {
await doc.loadInfo()
const sheet = doc.sheetsById[Number(options.sheetId)]
await sheet.addRow(parsedValues)
logs.push({
status: 'success',
description: `Succesfully inserted row in ${doc.title} > ${sheet.title}`,
})
} catch (err) {
logs.push({
status: 'error',
description: `An error occured while inserting the row`,
details: err,
})
}
return { outgoingEdgeId, logs }
}

View File

@ -0,0 +1,73 @@
import {
SessionState,
GoogleSheetsUpdateRowOptions,
ReplyLog,
} from '@typebot.io/schemas'
import { parseCellValues } from './helpers/parseCellValues'
import { getAuthenticatedGoogleDoc } from './helpers/getAuthenticatedGoogleDoc'
import { ExecuteIntegrationResponse } from '../../../types'
import { matchFilter } from './helpers/matchFilter'
import { deepParseVariables } from '../../../variables/deepParseVariables'
export const updateRow = async (
state: SessionState,
{
outgoingEdgeId,
options,
}: { outgoingEdgeId?: string; options: GoogleSheetsUpdateRowOptions }
): Promise<ExecuteIntegrationResponse> => {
const { variables } = state.typebotsQueue[0].typebot
const { sheetId, referenceCell, filter } =
deepParseVariables(variables)(options)
if (!options.cellsToUpsert || !sheetId || (!referenceCell && !filter))
return { outgoingEdgeId }
const logs: ReplyLog[] = []
const doc = await getAuthenticatedGoogleDoc({
credentialsId: options.credentialsId,
spreadsheetId: options.spreadsheetId,
})
const parsedValues = parseCellValues(variables)(options.cellsToUpsert)
await doc.loadInfo()
const sheet = doc.sheetsById[Number(sheetId)]
const rows = await sheet.getRows()
const filteredRows = rows.filter((row) =>
referenceCell
? row.get(referenceCell.column as string) === referenceCell.value
: matchFilter(row, filter as NonNullable<typeof filter>)
)
if (filteredRows.length === 0) {
logs.push({
status: 'info',
description: `Could not find any row that matches the filter`,
details: filter,
})
return { outgoingEdgeId, logs }
}
try {
for (const filteredRow of filteredRows) {
const rowIndex = filteredRow.rowNumber - 2 // -1 for 1-indexing, -1 for header row
for (const key in parsedValues) {
rows[rowIndex].set(key, parsedValues[key])
}
await rows[rowIndex].save()
}
logs.push({
status: 'success',
description: `Succesfully updated matching rows`,
})
} catch (err) {
console.log(err)
logs.push({
status: 'error',
description: `An error occured while updating the row`,
details: err,
})
}
return { outgoingEdgeId, logs }
}

View File

@ -0,0 +1,169 @@
import {
Block,
BubbleBlockType,
SessionState,
TypebotInSession,
} from '@typebot.io/schemas'
import {
ChatCompletionOpenAIOptions,
OpenAICredentials,
chatCompletionMessageRoles,
} from '@typebot.io/schemas/features/blocks/integrations/openai'
import { byId, isEmpty } from '@typebot.io/lib'
import { decrypt, isCredentialsV2 } from '@typebot.io/lib/api/encryption'
import { resumeChatCompletion } from './resumeChatCompletion'
import { parseChatCompletionMessages } from './parseChatCompletionMessages'
import { executeChatCompletionOpenAIRequest } from './executeChatCompletionOpenAIRequest'
import { isPlaneteScale } from '@typebot.io/lib/isPlanetScale'
import prisma from '@typebot.io/lib/prisma'
import { ExecuteIntegrationResponse } from '../../../types'
import { parseVariableNumber } from '../../../variables/parseVariableNumber'
import { updateVariablesInSession } from '../../../variables/updateVariablesInSession'
export const createChatCompletionOpenAI = async (
state: SessionState,
{
outgoingEdgeId,
options,
blockId,
}: {
outgoingEdgeId?: string
options: ChatCompletionOpenAIOptions
blockId: string
}
): Promise<ExecuteIntegrationResponse> => {
let newSessionState = state
const noCredentialsError = {
status: 'error',
description: 'Make sure to select an OpenAI account',
}
if (!options.credentialsId) {
return {
outgoingEdgeId,
logs: [noCredentialsError],
}
}
const credentials = await prisma.credentials.findUnique({
where: {
id: options.credentialsId,
},
})
if (!credentials) {
console.error('Could not find credentials in database')
return { outgoingEdgeId, logs: [noCredentialsError] }
}
const { apiKey } = (await decrypt(
credentials.data,
credentials.iv
)) as OpenAICredentials['data']
const { typebot } = newSessionState.typebotsQueue[0]
const { variablesTransformedToList, messages } = parseChatCompletionMessages(
typebot.variables
)(options.messages)
if (variablesTransformedToList.length > 0)
newSessionState = updateVariablesInSession(state)(
variablesTransformedToList
)
const temperature = parseVariableNumber(typebot.variables)(
options.advancedSettings?.temperature
)
if (
isPlaneteScale() &&
isCredentialsV2(credentials) &&
newSessionState.isStreamEnabled &&
!newSessionState.whatsApp
) {
const assistantMessageVariableName = typebot.variables.find(
(variable) =>
options.responseMapping.find(
(m) => m.valueToExtract === 'Message content'
)?.variableId === variable.id
)?.name
return {
clientSideActions: [
{
streamOpenAiChatCompletion: {
messages: messages as {
content?: string
role: (typeof chatCompletionMessageRoles)[number]
}[],
displayStream: isNextBubbleMessageWithAssistantMessage(typebot)(
blockId,
assistantMessageVariableName
),
},
expectsDedicatedReply: true,
},
],
outgoingEdgeId,
newSessionState,
}
}
const { response, logs } = await executeChatCompletionOpenAIRequest({
apiKey,
messages,
model: options.model,
temperature,
baseUrl: options.baseUrl,
apiVersion: options.apiVersion,
})
if (!response)
return {
outgoingEdgeId,
logs,
}
const messageContent = response.choices.at(0)?.message?.content
const totalTokens = response.usage?.total_tokens
if (isEmpty(messageContent)) {
console.error('OpenAI block returned empty message', response)
return { outgoingEdgeId, newSessionState }
}
return resumeChatCompletion(newSessionState, {
options,
outgoingEdgeId,
logs,
})(messageContent, totalTokens)
}
const isNextBubbleMessageWithAssistantMessage =
(typebot: TypebotInSession) =>
(blockId: string, assistantVariableName?: string): boolean => {
if (!assistantVariableName) return false
const nextBlock = getNextBlock(typebot)(blockId)
if (!nextBlock) return false
return (
nextBlock.type === BubbleBlockType.TEXT &&
nextBlock.content.richText?.length > 0 &&
nextBlock.content.richText?.at(0)?.children.at(0).text ===
`{{${assistantVariableName}}}`
)
}
const getNextBlock =
(typebot: TypebotInSession) =>
(blockId: string): Block | undefined => {
const group = typebot.groups.find((group) =>
group.blocks.find(byId(blockId))
)
if (!group) return
const blockIndex = group.blocks.findIndex(byId(blockId))
const nextBlockInGroup = group.blocks.at(blockIndex + 1)
if (nextBlockInGroup) return nextBlockInGroup
const outgoingEdgeId = group.blocks.at(blockIndex)?.outgoingEdgeId
if (!outgoingEdgeId) return
const outgoingEdge = typebot.edges.find(byId(outgoingEdgeId))
if (!outgoingEdge) return
const connectedGroup = typebot.groups.find(byId(outgoingEdge?.to.groupId))
if (!connectedGroup) return
return outgoingEdge.to.blockId
? connectedGroup.blocks.find(
(block) => block.id === outgoingEdge.to.blockId
)
: connectedGroup?.blocks.at(0)
}

View File

@ -0,0 +1,115 @@
import { isNotEmpty } from '@typebot.io/lib/utils'
import { ChatReply } from '@typebot.io/schemas'
import { OpenAIBlock } from '@typebot.io/schemas/features/blocks/integrations/openai'
import { HTTPError } from 'got'
import {
Configuration,
OpenAIApi,
type CreateChatCompletionRequest,
type CreateChatCompletionResponse,
ResponseTypes,
} from 'openai-edge'
type Props = Pick<CreateChatCompletionRequest, 'messages' | 'model'> & {
apiKey: string
temperature: number | undefined
currentLogs?: ChatReply['logs']
isRetrying?: boolean
} & Pick<OpenAIBlock['options'], 'apiVersion' | 'baseUrl'>
export const executeChatCompletionOpenAIRequest = async ({
apiKey,
model,
messages,
temperature,
baseUrl,
apiVersion,
isRetrying,
currentLogs = [],
}: Props): Promise<{
response?: CreateChatCompletionResponse
logs?: ChatReply['logs']
}> => {
const logs: ChatReply['logs'] = currentLogs
if (messages.length === 0) return { logs }
try {
const config = new Configuration({
apiKey,
basePath: baseUrl,
baseOptions: {
headers: {
'api-key': apiKey,
},
},
defaultQueryParams: isNotEmpty(apiVersion)
? new URLSearchParams({
'api-version': apiVersion,
})
: undefined,
})
const openai = new OpenAIApi(config)
const response = await openai.createChatCompletion({
model,
messages,
temperature,
})
const completion =
(await response.json()) as ResponseTypes['createChatCompletion']
return { response: completion, logs }
} catch (error) {
if (error instanceof HTTPError) {
if (
(error.response.statusCode === 503 ||
error.response.statusCode === 500 ||
error.response.statusCode === 403) &&
!isRetrying
) {
console.log('OpenAI API error - 503, retrying in 3 seconds')
await new Promise((resolve) => setTimeout(resolve, 3000))
return executeChatCompletionOpenAIRequest({
apiKey,
model,
messages,
temperature,
currentLogs: logs,
baseUrl,
apiVersion,
isRetrying: true,
})
}
if (error.response.statusCode === 400) {
const log = {
status: 'info',
description:
'Max tokens limit reached, automatically trimming first message.',
}
logs.push(log)
return executeChatCompletionOpenAIRequest({
apiKey,
model,
messages: messages.slice(1),
temperature,
currentLogs: logs,
baseUrl,
apiVersion,
})
}
logs.push({
status: 'error',
description: `OpenAI API error - ${error.response.statusCode}`,
details: error.response.body,
})
return { logs }
}
logs.push({
status: 'error',
description: `Internal error`,
details: error,
})
return { logs }
}
}

View File

@ -0,0 +1,21 @@
import { SessionState } from '@typebot.io/schemas'
import { OpenAIBlock } from '@typebot.io/schemas/features/blocks/integrations/openai'
import { createChatCompletionOpenAI } from './createChatCompletionOpenAI'
import { ExecuteIntegrationResponse } from '../../../types'
export const executeOpenAIBlock = async (
state: SessionState,
block: OpenAIBlock
): Promise<ExecuteIntegrationResponse> => {
switch (block.options.task) {
case 'Create chat completion':
return createChatCompletionOpenAI(state, {
options: block.options,
outgoingEdgeId: block.outgoingEdgeId,
blockId: block.id,
})
case 'Create image':
case undefined:
return { outgoingEdgeId: block.outgoingEdgeId }
}
}

View File

@ -0,0 +1,71 @@
import { Connection } from '@planetscale/database'
import { decrypt } from '@typebot.io/lib/api/encryption'
import { isNotEmpty } from '@typebot.io/lib/utils'
import {
ChatCompletionOpenAIOptions,
OpenAICredentials,
} from '@typebot.io/schemas/features/blocks/integrations/openai'
import { SessionState } from '@typebot.io/schemas/features/chat/sessionState'
import { OpenAIStream } from 'ai'
import {
ChatCompletionRequestMessage,
Configuration,
OpenAIApi,
} from 'openai-edge'
import { parseVariableNumber } from '../../../variables/parseVariableNumber'
export const getChatCompletionStream =
(conn: Connection) =>
async (
state: SessionState,
options: ChatCompletionOpenAIOptions,
messages: ChatCompletionRequestMessage[]
) => {
if (!options.credentialsId) return
const credentials = (
await conn.execute('select data, iv from Credentials where id=?', [
options.credentialsId,
])
).rows.at(0) as { data: string; iv: string } | undefined
if (!credentials) {
console.error('Could not find credentials in database')
return
}
const { apiKey } = (await decrypt(
credentials.data,
credentials.iv
)) as OpenAICredentials['data']
const { typebot } = state.typebotsQueue[0]
const temperature = parseVariableNumber(typebot.variables)(
options.advancedSettings?.temperature
)
const config = new Configuration({
apiKey,
basePath: options.baseUrl,
baseOptions: {
headers: {
'api-key': apiKey,
},
},
defaultQueryParams: isNotEmpty(options.apiVersion)
? new URLSearchParams({
'api-version': options.apiVersion,
})
: undefined,
})
const openai = new OpenAIApi(config)
const response = await openai.createChatCompletion({
model: options.model,
temperature,
stream: true,
messages,
})
if (!response.ok) return response
return OpenAIStream(response)
}

View File

@ -0,0 +1,90 @@
import { byId, isNotEmpty } from '@typebot.io/lib'
import { Variable, VariableWithValue } from '@typebot.io/schemas'
import { ChatCompletionOpenAIOptions } from '@typebot.io/schemas/features/blocks/integrations/openai'
import type { ChatCompletionRequestMessage } from 'openai-edge'
import { parseVariables } from '../../../variables/parseVariables'
import { transformStringVariablesToList } from '../../../variables/transformVariablesToList'
export const parseChatCompletionMessages =
(variables: Variable[]) =>
(
messages: ChatCompletionOpenAIOptions['messages']
): {
variablesTransformedToList: VariableWithValue[]
messages: ChatCompletionRequestMessage[]
} => {
const variablesTransformedToList: VariableWithValue[] = []
const parsedMessages = messages
.flatMap((message) => {
if (!message.role) return
if (message.role === 'Messages sequence ✨') {
if (
!message.content?.assistantMessagesVariableId ||
!message.content?.userMessagesVariableId
)
return
variablesTransformedToList.push(
...transformStringVariablesToList(variables)([
message.content.assistantMessagesVariableId,
message.content.userMessagesVariableId,
])
)
const updatedVariables = variables.map((variable) => {
const variableTransformedToList = variablesTransformedToList.find(
byId(variable.id)
)
if (variableTransformedToList) return variableTransformedToList
return variable
})
const userMessages = (updatedVariables.find(
(variable) =>
variable.id === message.content?.userMessagesVariableId
)?.value ?? []) as string[]
const assistantMessages = (updatedVariables.find(
(variable) =>
variable.id === message.content?.assistantMessagesVariableId
)?.value ?? []) as string[]
let allMessages: ChatCompletionRequestMessage[] = []
if (userMessages.length > assistantMessages.length)
allMessages = userMessages.flatMap((userMessage, index) => [
{
role: 'user',
content: userMessage,
},
{ role: 'assistant', content: assistantMessages.at(index) ?? '' },
]) satisfies ChatCompletionRequestMessage[]
else {
allMessages = assistantMessages.flatMap(
(assistantMessage, index) => [
{ role: 'assistant', content: assistantMessage },
{
role: 'user',
content: userMessages.at(index) ?? '',
},
]
) satisfies ChatCompletionRequestMessage[]
}
return allMessages
}
return {
role: message.role,
content: parseVariables(variables)(message.content),
name: message.name
? parseVariables(variables)(message.name)
: undefined,
} satisfies ChatCompletionRequestMessage
})
.filter(
(message) => isNotEmpty(message?.role) && isNotEmpty(message?.content)
) as ChatCompletionRequestMessage[]
return {
variablesTransformedToList,
messages: parsedMessages,
}
}

View File

@ -0,0 +1,51 @@
import { byId, isDefined } from '@typebot.io/lib'
import { ChatReply, SessionState } from '@typebot.io/schemas'
import { ChatCompletionOpenAIOptions } from '@typebot.io/schemas/features/blocks/integrations/openai'
import { VariableWithUnknowValue } from '@typebot.io/schemas/features/typebot/variable'
import { updateVariablesInSession } from '../../../variables/updateVariablesInSession'
export const resumeChatCompletion =
(
state: SessionState,
{
outgoingEdgeId,
options,
logs = [],
}: {
outgoingEdgeId?: string
options: ChatCompletionOpenAIOptions
logs?: ChatReply['logs']
}
) =>
async (message: string, totalTokens?: number) => {
let newSessionState = state
const newVariables = options.responseMapping.reduce<
VariableWithUnknowValue[]
>((newVariables, mapping) => {
const { typebot } = newSessionState.typebotsQueue[0]
const existingVariable = typebot.variables.find(byId(mapping.variableId))
if (!existingVariable) return newVariables
if (mapping.valueToExtract === 'Message content') {
newVariables.push({
...existingVariable,
value: Array.isArray(existingVariable.value)
? existingVariable.value.concat(message)
: message,
})
}
if (mapping.valueToExtract === 'Total tokens' && isDefined(totalTokens)) {
newVariables.push({
...existingVariable,
value: totalTokens,
})
}
return newVariables
}, [])
if (newVariables.length > 0)
newSessionState = updateVariablesInSession(newSessionState)(newVariables)
return {
outgoingEdgeId,
newSessionState,
logs,
}
}

View File

@ -0,0 +1,32 @@
import { PixelBlock, SessionState } from '@typebot.io/schemas'
import { ExecuteIntegrationResponse } from '../../../types'
import { deepParseVariables } from '../../../variables/deepParseVariables'
export const executePixelBlock = (
state: SessionState,
block: PixelBlock
): ExecuteIntegrationResponse => {
const { typebot, resultId } = state.typebotsQueue[0]
if (
!resultId ||
!block.options.pixelId ||
!block.options.eventType ||
state.whatsApp
)
return { outgoingEdgeId: block.outgoingEdgeId }
const pixel = deepParseVariables(typebot.variables, {
guessCorrectTypes: true,
removeEmptyStrings: true,
})(block.options)
return {
outgoingEdgeId: block.outgoingEdgeId,
clientSideActions: [
{
pixel: {
...pixel,
pixelId: block.options.pixelId,
},
},
],
}
}

View File

@ -0,0 +1,16 @@
import { env } from '@typebot.io/env'
export const defaultTransportOptions = {
host: env.SMTP_HOST,
port: env.SMTP_PORT,
secure: env.SMTP_SECURE,
auth: {
user: env.SMTP_USERNAME,
pass: env.SMTP_PASSWORD,
},
}
export const defaultFrom = {
name: env.NEXT_PUBLIC_SMTP_FROM?.split(' <')[0].replace(/"/g, ''),
email: env.NEXT_PUBLIC_SMTP_FROM?.match(/<(.*)>/)?.pop(),
}

View File

@ -0,0 +1,272 @@
import { DefaultBotNotificationEmail, render } from '@typebot.io/emails'
import {
AnswerInSessionState,
ReplyLog,
SendEmailBlock,
SendEmailOptions,
SessionState,
SmtpCredentials,
TypebotInSession,
Variable,
} from '@typebot.io/schemas'
import { createTransport } from 'nodemailer'
import Mail from 'nodemailer/lib/mailer'
import { byId, isDefined, isEmpty, isNotDefined, omit } from '@typebot.io/lib'
import { getDefinedVariables, parseAnswers } from '@typebot.io/lib/results'
import { decrypt } from '@typebot.io/lib/api'
import { defaultFrom, defaultTransportOptions } from './constants'
import { findUniqueVariableValue } from '../../../variables/findUniqueVariableValue'
import { env } from '@typebot.io/env'
import { ExecuteIntegrationResponse } from '../../../types'
import prisma from '@typebot.io/lib/prisma'
import { parseVariables } from '../../../variables/parseVariables'
export const executeSendEmailBlock = async (
state: SessionState,
block: SendEmailBlock
): Promise<ExecuteIntegrationResponse> => {
const logs: ReplyLog[] = []
const { options } = block
const { typebot, resultId, answers } = state.typebotsQueue[0]
const isPreview = !resultId
if (isPreview)
return {
outgoingEdgeId: block.outgoingEdgeId,
logs: [
{
status: 'info',
description: 'Emails are not sent in preview mode',
},
],
}
const bodyUniqueVariable = findUniqueVariableValue(typebot.variables)(
options.body
)
const body = bodyUniqueVariable
? stringifyUniqueVariableValueAsHtml(bodyUniqueVariable)
: parseVariables(typebot.variables, { isInsideHtml: true })(
options.body ?? ''
)
try {
const sendEmailLogs = await sendEmail({
typebot,
answers,
credentialsId: options.credentialsId,
recipients: options.recipients.map(parseVariables(typebot.variables)),
subject: parseVariables(typebot.variables)(options.subject ?? ''),
body,
cc: (options.cc ?? []).map(parseVariables(typebot.variables)),
bcc: (options.bcc ?? []).map(parseVariables(typebot.variables)),
replyTo: options.replyTo
? parseVariables(typebot.variables)(options.replyTo)
: undefined,
fileUrls: getFileUrls(typebot.variables)(options.attachmentsVariableId),
isCustomBody: options.isCustomBody,
isBodyCode: options.isBodyCode,
})
if (sendEmailLogs) logs.push(...sendEmailLogs)
} catch (err) {
logs.push({
status: 'error',
details: err,
description: `Email not sent`,
})
}
return { outgoingEdgeId: block.outgoingEdgeId, logs }
}
const sendEmail = async ({
typebot,
answers,
credentialsId,
recipients,
body,
subject,
cc,
bcc,
replyTo,
isBodyCode,
isCustomBody,
fileUrls,
}: SendEmailOptions & {
typebot: TypebotInSession
answers: AnswerInSessionState[]
fileUrls?: string | string[]
}): Promise<ReplyLog[] | undefined> => {
const logs: ReplyLog[] = []
const { name: replyToName } = parseEmailRecipient(replyTo)
const { host, port, isTlsEnabled, username, password, from } =
(await getEmailInfo(credentialsId)) ?? {}
if (!from) return
const transportConfig = {
host,
port,
secure: isTlsEnabled ?? undefined,
auth: {
user: username,
pass: password,
},
}
const emailBody = await getEmailBody({
body,
isCustomBody,
isBodyCode,
typebot,
answersInSession: answers,
})
if (!emailBody) {
logs.push({
status: 'error',
description: 'Email not sent',
details: {
error: 'No email body found',
transportConfig,
recipients,
subject,
cc,
bcc,
replyTo,
emailBody,
},
})
return logs
}
const transporter = createTransport(transportConfig)
const fromName = isEmpty(replyToName) ? from.name : replyToName
const email: Mail.Options = {
from: fromName ? `"${fromName}" <${from.email}>` : from.email,
cc,
bcc,
to: recipients,
replyTo,
subject,
attachments: fileUrls
? (typeof fileUrls === 'string' ? fileUrls.split(', ') : fileUrls).map(
(url) => ({ path: url })
)
: undefined,
...emailBody,
}
try {
await transporter.sendMail(email)
logs.push({
status: 'success',
description: 'Email successfully sent',
details: {
transportConfig: {
...transportConfig,
auth: { user: transportConfig.auth.user, pass: '******' },
},
email,
},
})
} catch (err) {
logs.push({
status: 'error',
description: 'Email not sent',
details: {
error: err instanceof Error ? err.toString() : err,
transportConfig: {
...transportConfig,
auth: { user: transportConfig.auth.user, pass: '******' },
},
email,
},
})
}
return logs
}
const getEmailInfo = async (
credentialsId: string
): Promise<SmtpCredentials['data'] | undefined> => {
if (credentialsId === 'default')
return {
host: defaultTransportOptions.host,
port: defaultTransportOptions.port,
username: defaultTransportOptions.auth.user,
password: defaultTransportOptions.auth.pass,
isTlsEnabled: undefined,
from: defaultFrom,
}
const credentials = await prisma.credentials.findUnique({
where: { id: credentialsId },
})
if (!credentials) return
return (await decrypt(
credentials.data,
credentials.iv
)) as SmtpCredentials['data']
}
const getEmailBody = async ({
body,
isCustomBody,
isBodyCode,
typebot,
answersInSession,
}: {
typebot: TypebotInSession
answersInSession: AnswerInSessionState[]
} & Pick<SendEmailOptions, 'isCustomBody' | 'isBodyCode' | 'body'>): Promise<
{ html?: string; text?: string } | undefined
> => {
if (isCustomBody || (isNotDefined(isCustomBody) && !isEmpty(body)))
return {
html: isBodyCode ? body : undefined,
text: !isBodyCode ? body : undefined,
}
const answers = parseAnswers({
variables: getDefinedVariables(typebot.variables),
answers: answersInSession,
})
return {
html: render(
<DefaultBotNotificationEmail
resultsUrl={`${env.NEXTAUTH_URL}/typebots/${typebot.id}/results`}
answers={omit(answers, 'submittedAt')}
/>
).html,
}
}
const parseEmailRecipient = (
recipient?: string
): { email?: string; name?: string } => {
if (!recipient) return {}
if (recipient.includes('<')) {
const [name, email] = recipient.split('<')
return {
name: name.replace(/>/g, '').trim().replace(/"/g, ''),
email: email.replace('>', '').trim(),
}
}
return {
email: recipient,
}
}
const getFileUrls =
(variables: Variable[]) =>
(variableId: string | undefined): string | string[] | undefined => {
const fileUrls = variables.find(byId(variableId))?.value
if (!fileUrls) return
if (typeof fileUrls === 'string') return fileUrls
return fileUrls.filter(isDefined)
}
const stringifyUniqueVariableValueAsHtml = (
value: Variable['value']
): string => {
if (!value) return ''
if (typeof value === 'string') return value.replace(/\n/g, '<br />')
return value.map(stringifyUniqueVariableValueAsHtml).join('<br />')
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,129 @@
import { SessionState } from '@typebot.io/schemas'
import {
ZemanticAiBlock,
ZemanticAiCredentials,
ZemanticAiResponse,
} from '@typebot.io/schemas/features/blocks/integrations/zemanticAi'
import got from 'got'
import { decrypt } from '@typebot.io/lib/api/encryption'
import { byId, isDefined, isEmpty } from '@typebot.io/lib'
import { getDefinedVariables, parseAnswers } from '@typebot.io/lib/results'
import prisma from '@typebot.io/lib/prisma'
import { ExecuteIntegrationResponse } from '../../../types'
import { updateVariablesInSession } from '../../../variables/updateVariablesInSession'
const URL = 'https://api.zemantic.ai/v1/search-documents'
export const executeZemanticAiBlock = async (
state: SessionState,
block: ZemanticAiBlock
): 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',
}
const credentials = await prisma.credentials.findUnique({
where: {
id: block.options.credentialsId,
},
})
if (!credentials) {
console.error('Could not find credentials in database')
return {
outgoingEdgeId: block.outgoingEdgeId,
logs: [noCredentialsError],
}
}
const { apiKey } = (await decrypt(
credentials.data,
credentials.iv
)) as ZemanticAiCredentials['data']
const { typebot, answers } = newSessionState.typebotsQueue[0]
const templateVars = parseAnswers({
variables: getDefinedVariables(typebot.variables),
answers: answers,
})
try {
const res: ZemanticAiResponse = await got
.post(URL, {
headers: {
Authorization: `Bearer ${apiKey}`,
},
json: {
projectId: block.options.projectId,
query: replaceTemplateVars(
block.options.query as string,
templateVars
),
maxResults: block.options.maxResults,
summarize: true,
summaryOptions: {
system_prompt:
replaceTemplateVars(
block.options.systemPrompt as string,
templateVars
) ?? '',
prompt:
replaceTemplateVars(
block.options.prompt as string,
templateVars
) ?? '',
},
},
})
.json()
for (const r of block.options.responseMapping || []) {
const variable = typebot.variables.find(byId(r.variableId))
switch (r.valueToExtract) {
case 'Summary':
if (isDefined(variable) && !isEmpty(res.summary)) {
newSessionState = updateVariablesInSession(newSessionState)([
{ ...variable, value: res.summary },
])
}
break
case 'Results':
if (isDefined(variable) && res.results.length) {
newSessionState = updateVariablesInSession(newSessionState)([
{ ...variable, value: JSON.stringify(res.results) },
])
}
break
default:
break
}
}
} catch (e) {
console.error(e)
return {
outgoingEdgeId: block.outgoingEdgeId,
logs: [zemanticRequestError],
}
}
return { outgoingEdgeId: block.outgoingEdgeId, newSessionState }
}
const replaceTemplateVars = (
template: string,
vars: Record<string, string>
) => {
if (!template) return
let result = template
for (const [key, value] of Object.entries(vars)) {
result = result.replaceAll(`{{${key}}}`, value)
}
return result
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,25 @@
import {
TypingEmulation,
defaultSettings,
} from '@typebot.io/schemas/features/typebot/settings'
type Props = {
bubbleContent: string
typingSettings?: TypingEmulation
}
export const computeTypingDuration = ({
bubbleContent,
typingSettings = defaultSettings({ isBrandingEnabled: false })
.typingEmulation,
}: 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
return typingTimeout
}

View File

@ -0,0 +1,375 @@
import {
AnswerInSessionState,
Block,
BubbleBlockType,
ChatReply,
InputBlock,
InputBlockType,
IntegrationBlockType,
LogicBlockType,
SessionState,
defaultPaymentInputOptions,
invalidEmailDefaultRetryMessage,
} from '@typebot.io/schemas'
import { isInputBlock, byId } from '@typebot.io/lib'
import { executeGroup, parseInput } from './executeGroup'
import { getNextGroup } from './getNextGroup'
import { validateEmail } from './blocks/inputs/email/validateEmail'
import { formatPhoneNumber } from './blocks/inputs/phone/formatPhoneNumber'
import { validateUrl } from './blocks/inputs/url/validateUrl'
import { resumeChatCompletion } from './blocks/integrations/openai/resumeChatCompletion'
import { resumeWebhookExecution } from './blocks/integrations/webhook/resumeWebhookExecution'
import { upsertAnswer } from './queries/upsertAnswer'
import { startBotFlow } from './startBotFlow'
import { parseButtonsReply } from './blocks/inputs/buttons/parseButtonsReply'
import { ParsedReply } 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 './variables/parseVariables'
import { updateVariablesInSession } from './variables/updateVariablesInSession'
export const continueBotFlow =
(state: SessionState) =>
async (
reply?: string
): Promise<ChatReply & { newSessionState: SessionState }> => {
let newSessionState = { ...state }
const group = state.typebotsQueue[0].typebot.groups.find(
(group) => group.id === state.currentBlock?.groupId
)
const blockIndex =
group?.blocks.findIndex(
(block) => block.id === state.currentBlock?.blockId
) ?? -1
const block = blockIndex >= 0 ? group?.blocks[blockIndex ?? 0] : null
if (!block || !group) return startBotFlow(state)
if (block.type === LogicBlockType.SET_VARIABLE) {
const existingVariable = state.typebotsQueue[0].typebot.variables.find(
byId(block.options.variableId)
)
if (existingVariable && reply) {
const newVariable = {
...existingVariable,
value: safeJsonParse(reply),
}
newSessionState = updateVariablesInSession(state)([newVariable])
}
} else if (reply && block.type === IntegrationBlockType.WEBHOOK) {
const result = resumeWebhookExecution({
state,
block,
response: JSON.parse(reply),
})
if (result.newSessionState) newSessionState = result.newSessionState
} else if (
block.type === IntegrationBlockType.OPEN_AI &&
block.options.task === 'Create chat completion'
) {
if (reply) {
const result = await resumeChatCompletion(state, {
options: block.options,
outgoingEdgeId: block.outgoingEdgeId,
})(reply)
newSessionState = result.newSessionState
}
}
let formattedReply: string | undefined
if (isInputBlock(block)) {
const parsedReplyResult = parseReply(newSessionState)(reply, block)
if (parsedReplyResult.status === 'fail')
return {
...(await parseRetryMessage(newSessionState)(block)),
newSessionState,
}
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)
}
const groupHasMoreBlocks = blockIndex < group.blocks.length - 1
const nextEdgeId = getOutgoingEdgeId(newSessionState)(block, formattedReply)
if (groupHasMoreBlocks && !nextEdgeId) {
const chatReply = await executeGroup(newSessionState)({
...group,
blocks: group.blocks.slice(blockIndex + 1),
})
return {
...chatReply,
lastMessageNewFormat:
formattedReply !== reply ? formattedReply : undefined,
}
}
if (!nextEdgeId && state.typebotsQueue.length === 1)
return {
messages: [],
newSessionState,
lastMessageNewFormat:
formattedReply !== reply ? formattedReply : undefined,
}
const nextGroup = await getNextGroup(newSessionState)(nextEdgeId)
newSessionState = nextGroup.newSessionState
if (!nextGroup.group)
return {
messages: [],
newSessionState,
lastMessageNewFormat:
formattedReply !== reply ? formattedReply : undefined,
}
const chatReply = await executeGroup(newSessionState)(nextGroup.group)
return {
...chatReply,
lastMessageNewFormat:
formattedReply !== reply ? formattedReply : undefined,
}
}
const processAndSaveAnswer =
(state: SessionState, block: InputBlock, itemId?: string) =>
async (reply: string | undefined): Promise<SessionState> => {
if (!reply) return state
let newState = await saveAnswer(state, block, itemId)(reply)
newState = saveVariableValueIfAny(newState, block)(reply)
return newState
}
const saveVariableValueIfAny =
(state: SessionState, block: InputBlock) =>
(reply: string): SessionState => {
if (!block.options.variableId) return state
const foundVariable = state.typebotsQueue[0].typebot.variables.find(
(variable) => variable.id === block.options.variableId
)
if (!foundVariable) return state
const newSessionState = updateVariablesInSession(state)([
{
...foundVariable,
value: Array.isArray(foundVariable.value)
? foundVariable.value.concat(reply)
: reply,
},
])
return newSessionState
}
const parseRetryMessage =
(state: SessionState) =>
async (block: InputBlock): Promise<Pick<ChatReply, 'messages' | 'input'>> => {
const retryMessage =
'retryMessageContent' in block.options &&
block.options.retryMessageContent
? block.options.retryMessageContent
: parseDefaultRetryMessage(block)
return {
messages: [
{
id: block.id,
type: BubbleBlockType.TEXT,
content: {
richText: [{ type: 'p', children: [{ text: retryMessage }] }],
},
},
],
input: await parseInput(state)(block),
}
}
const parseDefaultRetryMessage = (block: InputBlock): string => {
switch (block.type) {
case InputBlockType.EMAIL:
return invalidEmailDefaultRetryMessage
case InputBlockType.PAYMENT:
return defaultPaymentInputOptions.retryMessageContent as string
default:
return 'Invalid message. Please, try again.'
}
}
const saveAnswer =
(state: SessionState, block: InputBlock, itemId?: string) =>
async (reply: string): Promise<SessionState> => {
await upsertAnswer({
block,
answer: {
blockId: block.id,
itemId,
groupId: block.groupId,
content: reply,
variableId: block.options.variableId,
storageUsed: 0,
},
reply,
state,
itemId,
})
const key = block.options.variableId
? state.typebotsQueue[0].typebot.variables.find(
(variable) => variable.id === block.options.variableId
)?.name
: state.typebotsQueue[0].typebot.groups.find((group) =>
group.blocks.find((blockInGroup) => blockInGroup.id === block.id)
)?.title
return setNewAnswerInState(state)({
key: key ?? block.id,
value: reply,
})
}
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,
typebotsQueue: state.typebotsQueue.map((typebot, index) =>
index === 0
? {
...typebot,
answers: newAnswers,
}
: typebot
),
} satisfies SessionState
}
const getOutgoingEdgeId =
(state: Pick<SessionState, 'typebotsQueue'>) =>
(block: Block, reply: string | undefined) => {
const variables = state.typebotsQueue[0].typebot.variables
if (
block.type === InputBlockType.CHOICE &&
!block.options.isMultipleChoice &&
reply
) {
const matchedItem = block.items.find(
(item) =>
parseVariables(variables)(item.content).normalize() ===
reply.normalize()
)
if (matchedItem?.outgoingEdgeId) return matchedItem.outgoingEdgeId
}
if (
block.type === InputBlockType.PICTURE_CHOICE &&
!block.options.isMultipleChoice &&
reply
) {
const matchedItem = block.items.find(
(item) =>
parseVariables(variables)(item.title).normalize() ===
reply.normalize()
)
if (matchedItem?.outgoingEdgeId) return matchedItem.outgoingEdgeId
}
return block.outgoingEdgeId
}
const parseReply =
(state: SessionState) =>
(inputValue: string | undefined, block: InputBlock): ParsedReply => {
switch (block.type) {
case InputBlockType.EMAIL: {
if (!inputValue) return { status: 'fail' }
const isValid = validateEmail(inputValue)
if (!isValid) return { status: 'fail' }
return { status: 'success', reply: inputValue }
}
case InputBlockType.PHONE: {
if (!inputValue) return { status: 'fail' }
const formattedPhone = formatPhoneNumber(
inputValue,
block.options.defaultCountryCode
)
if (!formattedPhone) return { status: 'fail' }
return { status: 'success', reply: formattedPhone }
}
case InputBlockType.URL: {
if (!inputValue) return { status: 'fail' }
const isValid = validateUrl(inputValue)
if (!isValid) return { status: 'fail' }
return { status: 'success', reply: inputValue }
}
case InputBlockType.CHOICE: {
if (!inputValue) return { status: 'fail' }
return parseButtonsReply(state)(inputValue, block)
}
case InputBlockType.NUMBER: {
if (!inputValue) return { status: 'fail' }
const isValid = validateNumber(inputValue)
if (!isValid) return { status: 'fail' }
return { status: 'success', reply: inputValue }
}
case InputBlockType.DATE: {
if (!inputValue) return { status: 'fail' }
return parseDateReply(inputValue, block)
}
case InputBlockType.FILE: {
if (!inputValue)
return block.options.isRequired
? { status: 'fail' }
: { status: 'skip' }
return { status: 'success', reply: inputValue }
}
case InputBlockType.PAYMENT: {
if (!inputValue) return { status: 'fail' }
if (inputValue === 'fail') return { status: 'fail' }
return { status: 'success', reply: inputValue }
}
case InputBlockType.RATING: {
if (!inputValue) return { status: 'fail' }
const isValid = validateRatingReply(inputValue, block)
if (!isValid) return { status: 'fail' }
return { status: 'success', reply: inputValue }
}
case InputBlockType.PICTURE_CHOICE: {
if (!inputValue) return { status: 'fail' }
return parsePictureChoicesReply(state)(inputValue, block)
}
case InputBlockType.TEXT: {
if (!inputValue) return { status: 'fail' }
return { status: 'success', reply: inputValue }
}
}
}
export const safeJsonParse = (value: string): unknown => {
try {
return JSON.parse(value)
} catch {
return value
}
}

View File

@ -0,0 +1,234 @@
import {
BubbleBlock,
BubbleBlockType,
ChatReply,
Group,
InputBlock,
InputBlockType,
RuntimeOptions,
SessionState,
Variable,
} from '@typebot.io/schemas'
import {
isBubbleBlock,
isInputBlock,
isIntegrationBlock,
isLogicBlock,
isNotEmpty,
} from '@typebot.io/lib'
import { getNextGroup } from './getNextGroup'
import { executeLogic } from './executeLogic'
import { executeIntegration } from './executeIntegration'
import { computePaymentInputRuntimeOptions } from './blocks/inputs/payment/computePaymentInputRuntimeOptions'
import { injectVariableValuesInButtonsInputBlock } from './blocks/inputs/buttons/injectVariableValuesInButtonsInputBlock'
import { injectVariableValuesInPictureChoiceBlock } from './blocks/inputs/pictureChoice/injectVariableValuesInPictureChoiceBlock'
import { getPrefilledInputValue } from './getPrefilledValue'
import { parseDateInput } from './blocks/inputs/date/parseDateInput'
import { deepParseVariables } from './variables/deepParseVariables'
export const executeGroup =
(
state: SessionState,
currentReply?: ChatReply,
currentLastBubbleId?: string
) =>
async (
group: Group
): Promise<ChatReply & { newSessionState: SessionState }> => {
const messages: ChatReply['messages'] = currentReply?.messages ?? []
let clientSideActions: ChatReply['clientSideActions'] =
currentReply?.clientSideActions
let logs: ChatReply['logs'] = currentReply?.logs
let nextEdgeId = null
let lastBubbleBlockId: string | undefined = currentLastBubbleId
let newSessionState = state
for (const block of group.blocks) {
nextEdgeId = block.outgoingEdgeId
if (isBubbleBlock(block)) {
messages.push(
parseBubbleBlock(newSessionState.typebotsQueue[0].typebot.variables)(
block
)
)
lastBubbleBlockId = block.id
continue
}
if (isInputBlock(block))
return {
messages,
input: await parseInput(newSessionState)(block),
newSessionState: {
...newSessionState,
currentBlock: {
groupId: group.id,
blockId: block.id,
},
},
clientSideActions,
logs,
}
const executionResponse = isLogicBlock(block)
? await executeLogic(newSessionState)(block)
: isIntegrationBlock(block)
? await executeIntegration(newSessionState)(block)
: null
if (!executionResponse) continue
if (executionResponse.logs)
logs = [...(logs ?? []), ...executionResponse.logs]
if (executionResponse.newSessionState)
newSessionState = executionResponse.newSessionState
if (
'clientSideActions' in executionResponse &&
executionResponse.clientSideActions
) {
clientSideActions = [
...(clientSideActions ?? []),
...executionResponse.clientSideActions.map((action) => ({
...action,
lastBubbleBlockId,
})),
]
if (
executionResponse.clientSideActions?.find(
(action) => action.expectsDedicatedReply
)
) {
return {
messages,
newSessionState: {
...newSessionState,
currentBlock: {
groupId: group.id,
blockId: block.id,
},
},
clientSideActions,
logs,
}
}
}
if (executionResponse.outgoingEdgeId) {
nextEdgeId = executionResponse.outgoingEdgeId
break
}
}
if (!nextEdgeId && state.typebotsQueue.length === 1)
return { messages, newSessionState, clientSideActions, logs }
const nextGroup = await getNextGroup(newSessionState)(
nextEdgeId ?? undefined
)
newSessionState = nextGroup.newSessionState
if (!nextGroup.group) {
return { messages, newSessionState, clientSideActions, logs }
}
return executeGroup(
newSessionState,
{
messages,
clientSideActions,
logs,
},
lastBubbleBlockId
)(nextGroup.group)
}
const computeRuntimeOptions =
(state: SessionState) =>
(block: InputBlock): Promise<RuntimeOptions> | undefined => {
switch (block.type) {
case InputBlockType.PAYMENT: {
return computePaymentInputRuntimeOptions(state)(block.options)
}
}
}
const parseBubbleBlock =
(variables: Variable[]) =>
(block: BubbleBlock): ChatReply['messages'][0] => {
switch (block.type) {
case BubbleBlockType.TEXT:
return deepParseVariables(
variables,
{},
{ takeLatestIfList: true }
)(block)
case BubbleBlockType.EMBED: {
const message = deepParseVariables(variables)(block)
return {
...message,
content: {
...message.content,
height:
typeof message.content.height === 'string'
? parseFloat(message.content.height)
: message.content.height,
},
}
}
default:
return deepParseVariables(variables)(block)
}
}
export const parseInput =
(state: SessionState) =>
async (block: InputBlock): Promise<ChatReply['input']> => {
switch (block.type) {
case InputBlockType.CHOICE: {
return injectVariableValuesInButtonsInputBlock(state)(block)
}
case InputBlockType.PICTURE_CHOICE: {
return injectVariableValuesInPictureChoiceBlock(
state.typebotsQueue[0].typebot.variables
)(block)
}
case InputBlockType.NUMBER: {
const parsedBlock = deepParseVariables(
state.typebotsQueue[0].typebot.variables
)({
...block,
prefilledValue: getPrefilledInputValue(
state.typebotsQueue[0].typebot.variables
)(block),
})
return {
...parsedBlock,
options: {
...parsedBlock.options,
min: isNotEmpty(parsedBlock.options.min as string)
? Number(parsedBlock.options.min)
: undefined,
max: isNotEmpty(parsedBlock.options.max as string)
? Number(parsedBlock.options.max)
: undefined,
step: isNotEmpty(parsedBlock.options.step as string)
? Number(parsedBlock.options.step)
: undefined,
},
}
}
case InputBlockType.DATE: {
return parseDateInput(state)(block)
}
default: {
return deepParseVariables(state.typebotsQueue[0].typebot.variables)({
...block,
runtimeOptions: await computeRuntimeOptions(state)(block),
prefilledValue: getPrefilledInputValue(
state.typebotsQueue[0].typebot.variables
)(block),
})
}
}
}

View File

@ -0,0 +1,40 @@
import { executeOpenAIBlock } from './blocks/integrations/openai/executeOpenAIBlock'
import { executeSendEmailBlock } from './blocks/integrations/sendEmail/executeSendEmailBlock'
import { executeWebhookBlock } from './blocks/integrations/webhook/executeWebhookBlock'
import { executeChatwootBlock } from './blocks/integrations/chatwoot/executeChatwootBlock'
import { executeGoogleAnalyticsBlock } from './blocks/integrations/googleAnalytics/executeGoogleAnalyticsBlock'
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 { ExecuteIntegrationResponse } from './types'
export const executeIntegration =
(state: SessionState) =>
async (block: IntegrationBlock): Promise<ExecuteIntegrationResponse> => {
switch (block.type) {
case IntegrationBlockType.GOOGLE_SHEETS:
return executeGoogleSheetBlock(state, block)
case IntegrationBlockType.CHATWOOT:
return executeChatwootBlock(state, block)
case IntegrationBlockType.GOOGLE_ANALYTICS:
return executeGoogleAnalyticsBlock(state, block)
case IntegrationBlockType.EMAIL:
return executeSendEmailBlock(state, block)
case IntegrationBlockType.WEBHOOK:
case IntegrationBlockType.ZAPIER:
case IntegrationBlockType.MAKE_COM:
case IntegrationBlockType.PABBLY_CONNECT:
return executeWebhookBlock(state, block)
case IntegrationBlockType.OPEN_AI:
return executeOpenAIBlock(state, block)
case IntegrationBlockType.PIXEL:
return executePixelBlock(state, block)
case IntegrationBlockType.ZEMANTIC_AI:
return executeZemanticAiBlock(state, block)
}
}

View File

@ -0,0 +1,33 @@
import { executeWait } from './blocks/logic/wait/executeWait'
import { LogicBlock, LogicBlockType, SessionState } from '@typebot.io/schemas'
import { ExecuteLogicResponse } from './types'
import { executeScript } from './blocks/logic/script/executeScript'
import { executeJumpBlock } from './blocks/logic/jump/executeJumpBlock'
import { executeRedirect } from './blocks/logic/redirect/executeRedirect'
import { executeConditionBlock } from './blocks/logic/condition/executeConditionBlock'
import { executeSetVariable } from './blocks/logic/setVariable/executeSetVariable'
import { executeTypebotLink } from './blocks/logic/typebotLink/executeTypebotLink'
import { executeAbTest } from './blocks/logic/abTest/executeAbTest'
export const executeLogic =
(state: SessionState) =>
async (block: LogicBlock): Promise<ExecuteLogicResponse> => {
switch (block.type) {
case LogicBlockType.SET_VARIABLE:
return executeSetVariable(state, block)
case LogicBlockType.CONDITION:
return executeConditionBlock(state, block)
case LogicBlockType.REDIRECT:
return executeRedirect(state, block)
case LogicBlockType.SCRIPT:
return executeScript(state, block)
case LogicBlockType.TYPEBOT_LINK:
return executeTypebotLink(state, block)
case LogicBlockType.WAIT:
return executeWait(state, block)
case LogicBlockType.JUMP:
return executeJumpBlock(state, block.options)
case LogicBlockType.AB_TEST:
return executeAbTest(state, block)
}
}

View File

@ -0,0 +1,102 @@
import { byId, isDefined, isNotDefined } from '@typebot.io/lib'
import { Group, SessionState, VariableWithValue } from '@typebot.io/schemas'
import { upsertResult } from './queries/upsertResult'
export type NextGroup = {
group?: Group
newSessionState: SessionState
}
export const getNextGroup =
(state: SessionState) =>
async (edgeId?: string): Promise<NextGroup> => {
const nextEdge = state.typebotsQueue[0].typebot.edges.find(byId(edgeId))
if (!nextEdge) {
if (state.typebotsQueue.length > 1) {
const nextEdgeId = state.typebotsQueue[0].edgeIdToTriggerWhenDone
const isMergingWithParent = state.typebotsQueue[0].isMergingWithParent
const currentResultId = state.typebotsQueue[0].resultId
if (!isMergingWithParent && currentResultId)
await upsertResult({
resultId: currentResultId,
typebot: state.typebotsQueue[0].typebot,
isCompleted: true,
hasStarted: state.typebotsQueue[0].answers.length > 0,
})
const newSessionState = {
...state,
typebotsQueue: [
{
...state.typebotsQueue[1],
typebot: isMergingWithParent
? {
...state.typebotsQueue[1].typebot,
variables: state.typebotsQueue[1].typebot.variables
.map((variable) => ({
...variable,
value:
state.typebotsQueue[0].answers.find(
(answer) => answer.key === variable.name
)?.value ?? variable.value,
}))
.concat(
state.typebotsQueue[0].typebot.variables.filter(
(variable) =>
isDefined(variable.value) &&
isNotDefined(
state.typebotsQueue[1].typebot.variables.find(
(v) => v.name === variable.name
)
)
) as VariableWithValue[]
),
}
: state.typebotsQueue[1].typebot,
answers: isMergingWithParent
? [
...state.typebotsQueue[1].answers.filter(
(incomingAnswer) =>
!state.typebotsQueue[0].answers.find(
(currentAnswer) =>
currentAnswer.key === incomingAnswer.key
)
),
...state.typebotsQueue[0].answers,
]
: state.typebotsQueue[1].answers,
},
...state.typebotsQueue.slice(2),
],
} satisfies SessionState
const nextGroup = await getNextGroup(newSessionState)(nextEdgeId)
if (!nextGroup)
return {
newSessionState,
}
return {
...nextGroup,
newSessionState,
}
}
return {
newSessionState: state,
}
}
const nextGroup = state.typebotsQueue[0].typebot.groups.find(
byId(nextEdge.to.groupId)
)
if (!nextGroup)
return {
newSessionState: state,
}
const startBlockIndex = nextEdge.to.blockId
? nextGroup.blocks.findIndex(byId(nextEdge.to.blockId))
: 0
return {
group: {
...nextGroup,
blocks: nextGroup.blocks.slice(startBlockIndex),
},
newSessionState: state,
}
}

View File

@ -0,0 +1,13 @@
import { isDefined } from '@typebot.io/lib/utils'
import { InputBlock } from '@typebot.io/schemas/features/blocks/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)
)?.value
if (!variableValue || Array.isArray(variableValue)) return
return variableValue
}

View File

@ -0,0 +1,11 @@
import { isNotDefined } from '@typebot.io/lib/utils'
export const formatLogDetails = (details: unknown): string | null => {
if (isNotDefined(details)) return null
if (details instanceof Error) return details.toString()
try {
return JSON.stringify(details, null, 2).substring(0, 1000)
} catch {
return null
}
}

View File

@ -0,0 +1,11 @@
import { saveLog } from './saveLog'
export const saveErrorLog = ({
resultId,
message,
details,
}: {
resultId: string | undefined
message: string
details?: unknown
}) => saveLog({ status: 'error', resultId, message, details })

View File

@ -0,0 +1,21 @@
import prisma from '@typebot.io/lib/prisma'
import { formatLogDetails } from './helpers/formatLogDetails'
type Props = {
status: 'error' | 'success' | 'info'
resultId: string | undefined
message: string
details?: unknown
}
export const saveLog = ({ status, resultId, message, details }: Props) => {
if (!resultId || resultId === 'undefined') return
return prisma.log.create({
data: {
resultId,
status,
description: message,
details: formatLogDetails(details) as string | null,
},
})
}

View File

@ -0,0 +1,11 @@
import { saveLog } from './saveLog'
export const saveSuccessLog = ({
resultId,
message,
details,
}: {
resultId: string | undefined
message: string
details?: unknown
}) => saveLog({ status: 'success', resultId, message, details })

View File

@ -0,0 +1,38 @@
{
"name": "@typebot.io/bot-engine",
"version": "1.0.0",
"license": "AGPL-3.0-or-later",
"private": true,
"main": "./index.ts",
"types": "./index.ts",
"dependencies": {
"@paralleldrive/cuid2": "2.2.1",
"@planetscale/database": "^1.8.0",
"@sentry/nextjs": "7.66.0",
"@trpc/server": "10.34.0",
"@typebot.io/emails": "workspace:*",
"@typebot.io/env": "workspace:*",
"@typebot.io/lib": "workspace:*",
"@typebot.io/prisma": "workspace:*",
"@typebot.io/schemas": "workspace:*",
"@typebot.io/tsconfig": "workspace:*",
"@udecode/plate-common": "^21.1.5",
"ai": "2.1.32",
"chrono-node": "2.6.6",
"date-fns": "^2.30.0",
"google-auth-library": "8.9.0",
"google-spreadsheet": "4.0.2",
"got": "12.6.0",
"libphonenumber-js": "1.10.37",
"node-html-parser": "^6.1.5",
"nodemailer": "6.9.3",
"openai-edge": "1.2.2",
"qs": "^6.11.2",
"remark-slate": "^1.8.6",
"stripe": "12.13.0"
},
"devDependencies": {
"@types/nodemailer": "6.4.8",
"@types/qs": "6.9.7"
}
}

View File

@ -0,0 +1,16 @@
import { SessionState, ChatReply } from '@typebot.io/schemas'
import { parseVariables } from './variables/parseVariables'
export const parseDynamicTheme = (
state: SessionState | undefined
): ChatReply['dynamicTheme'] => {
if (!state?.dynamicTheme) return
return {
hostAvatarUrl: parseVariables(state?.typebotsQueue[0].typebot.variables)(
state.dynamicTheme.hostAvatarUrl
),
guestAvatarUrl: parseVariables(state?.typebotsQueue[0].typebot.variables)(
state.dynamicTheme.guestAvatarUrl
),
}
}

View File

@ -0,0 +1,33 @@
import prisma from '@typebot.io/lib/prisma'
import { getDefinedVariables } from '@typebot.io/lib/results'
import { TypebotInSession } from '@typebot.io/schemas'
type Props = {
resultId: string
typebot: TypebotInSession
hasStarted: boolean
isCompleted: boolean
}
export const createResultIfNotExist = async ({
resultId,
typebot,
hasStarted,
isCompleted,
}: Props) => {
const existingResult = await prisma.result.findUnique({
where: { id: resultId },
select: { id: true },
})
if (existingResult) return
return prisma.result.createMany({
data: [
{
id: resultId,
typebotId: typebot.id,
isCompleted: isCompleted ? true : false,
hasStarted,
variables: getDefinedVariables(typebot.variables),
},
],
})
}

View File

@ -0,0 +1,15 @@
import prisma from '@typebot.io/lib/prisma'
import { SessionState } from '@typebot.io/schemas'
type Props = {
id?: string
state: SessionState
}
export const createSession = async ({ id, state }: Props) =>
prisma.chatSession.create({
data: {
id,
state,
},
})

View File

@ -0,0 +1,8 @@
import prisma from '@typebot.io/lib/prisma'
export const deleteSession = (id: string) =>
prisma.chatSession.deleteMany({
where: {
id,
},
})

View File

@ -0,0 +1,35 @@
import prisma from '@typebot.io/lib/prisma'
type Props = {
publicId: string
}
export const findPublicTypebot = ({ publicId }: Props) =>
prisma.publicTypebot.findFirst({
where: { typebot: { publicId } },
select: {
version: true,
groups: true,
edges: true,
settings: true,
theme: true,
variables: true,
typebotId: true,
typebot: {
select: {
isArchived: true,
isClosed: true,
workspace: {
select: {
id: true,
plan: true,
additionalChatsIndex: true,
customChatsLimit: true,
isQuarantined: true,
isSuspended: true,
},
},
},
},
},
})

View File

@ -0,0 +1,27 @@
import prisma from '@typebot.io/lib/prisma'
import { Answer, Result } from '@typebot.io/schemas'
type Props = {
id: string
}
export const findResult = ({ id }: Props) =>
prisma.result.findFirst({
where: { id },
select: {
id: true,
variables: true,
hasStarted: true,
answers: {
select: {
content: true,
blockId: true,
variableId: true,
},
},
},
}) as Promise<
| (Pick<Result, 'id' | 'variables' | 'hasStarted'> & {
answers: Pick<Answer, 'content' | 'blockId' | 'variableId'>[]
})
| null
>

View File

@ -0,0 +1,21 @@
import prisma from '@typebot.io/lib/prisma'
type Props = {
id: string
userId?: string
}
export const findTypebot = ({ id, userId }: Props) =>
prisma.typebot.findFirst({
where: { id, workspace: { members: { some: { userId } } } },
select: {
version: true,
id: true,
groups: true,
edges: true,
settings: true,
theme: true,
variables: true,
isArchived: true,
},
})

View File

@ -0,0 +1,13 @@
import prisma from '@typebot.io/lib/prisma'
import { ChatSession, sessionStateSchema } from '@typebot.io/schemas'
export const getSession = async (
sessionId: string
): Promise<Pick<ChatSession, 'state' | 'id'> | null> => {
const session = await prisma.chatSession.findUnique({
where: { id: sessionId },
select: { id: true, state: true },
})
if (!session) return null
return { ...session, state: sessionStateSchema.parse(session.state) }
}

View File

@ -0,0 +1,24 @@
import prisma from '@typebot.io/lib/prisma'
import { SessionState } from '@typebot.io/schemas'
type Props = {
id?: string
state: SessionState
}
export const restartSession = async ({ id, state }: Props) => {
if (id) {
await prisma.chatSession.deleteMany({
where: {
id,
},
})
}
return prisma.chatSession.create({
data: {
id,
state,
},
})
}

View File

@ -0,0 +1,5 @@
import prisma from '@typebot.io/lib/prisma'
import { Log } from '@typebot.io/schemas'
export const saveLogs = (logs: Omit<Log, 'id' | 'createdAt'>[]) =>
prisma.log.createMany({ data: logs })

View File

@ -0,0 +1,15 @@
import prisma from '@typebot.io/lib/prisma'
import { SessionState } from '@typebot.io/schemas'
type Props = {
id: string
state: SessionState
}
export const updateSession = async ({ id, state }: Props) =>
prisma.chatSession.updateMany({
where: { id },
data: {
state,
},
})

View File

@ -0,0 +1,37 @@
import prisma from '@typebot.io/lib/prisma'
import { Prisma } from '@typebot.io/prisma'
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) => {
const resultId = state.typebotsQueue[0].resultId
if (!resultId) return
const where = {
resultId,
blockId: block.id,
groupId: block.groupId,
}
const existingAnswer = await prisma.answer.findUnique({
where: {
resultId_blockId_groupId: where,
},
select: { resultId: true },
})
if (existingAnswer)
return prisma.answer.updateMany({
where,
data: {
content: answer.content,
itemId: answer.itemId,
},
})
return prisma.answer.createMany({
data: [{ ...answer, resultId }],
})
}

View File

@ -0,0 +1,44 @@
import prisma from '@typebot.io/lib/prisma'
import { getDefinedVariables } from '@typebot.io/lib/results'
import { TypebotInSession } from '@typebot.io/schemas'
type Props = {
resultId: string
typebot: TypebotInSession
hasStarted: boolean
isCompleted: boolean
}
export const upsertResult = async ({
resultId,
typebot,
hasStarted,
isCompleted,
}: Props) => {
const existingResult = await prisma.result.findUnique({
where: { id: resultId },
select: { id: true },
})
const variablesWithValue = getDefinedVariables(typebot.variables)
if (existingResult) {
return prisma.result.updateMany({
where: { id: resultId },
data: {
isCompleted: isCompleted ? true : undefined,
hasStarted,
variables: variablesWithValue,
},
})
}
return prisma.result.createMany({
data: [
{
id: resultId,
typebotId: typebot.id,
isCompleted: isCompleted ? true : false,
hasStarted,
variables: variablesWithValue,
},
],
})
}

View File

@ -0,0 +1,63 @@
import { ChatReply, ChatSession } from '@typebot.io/schemas'
import { upsertResult } from './queries/upsertResult'
import { saveLogs } from './queries/saveLogs'
import { updateSession } from './queries/updateSession'
import { formatLogDetails } from './logs/helpers/formatLogDetails'
import { createSession } from './queries/createSession'
import { deleteSession } from './queries/deleteSession'
type Props = {
isFirstSave?: boolean
session: Pick<ChatSession, 'state'> & { id?: string }
input: ChatReply['input']
logs: ChatReply['logs']
clientSideActions: ChatReply['clientSideActions']
}
export const saveStateToDatabase = async ({
isFirstSave,
session: { state, id },
input,
logs,
clientSideActions,
}: Props) => {
const containsSetVariableClientSideAction = clientSideActions?.some(
(action) => action.expectsDedicatedReply
)
const isCompleted = Boolean(!input && !containsSetVariableClientSideAction)
const resultId = state.typebotsQueue[0].resultId
if (id) {
if (isCompleted && resultId) await deleteSession(id)
else await updateSession({ id, state })
}
const session =
id && !isFirstSave ? { state, id } : await createSession({ id, state })
if (!resultId) return session
const answers = state.typebotsQueue[0].answers
await upsertResult({
resultId,
typebot: state.typebotsQueue[0].typebot,
isCompleted: Boolean(
!input && !containsSetVariableClientSideAction && answers.length > 0
),
hasStarted: answers.length > 0,
})
if (logs && logs.length > 0)
await saveLogs(
logs.map((log) => ({
...log,
resultId,
details: formatLogDetails(log.details),
}))
)
return session
}

View File

@ -0,0 +1,27 @@
import { TRPCError } from '@trpc/server'
import { ChatReply, SessionState } from '@typebot.io/schemas'
import { executeGroup } from './executeGroup'
import { getNextGroup } from './getNextGroup'
export const startBotFlow = async (
state: SessionState,
startGroupId?: string
): Promise<ChatReply & { newSessionState: SessionState }> => {
if (startGroupId) {
const group = state.typebotsQueue[0].typebot.groups.find(
(group) => group.id === startGroupId
)
if (!group)
throw new TRPCError({
code: 'BAD_REQUEST',
message: "startGroupId doesn't exist",
})
return executeGroup(state)(group)
}
const firstEdgeId =
state.typebotsQueue[0].typebot.groups[0].blocks[0].outgoingEdgeId
if (!firstEdgeId) return { messages: [], newSessionState: state }
const nextGroup = await getNextGroup(state)(firstEdgeId)
if (!nextGroup.group) return { messages: [], newSessionState: state }
return executeGroup(state)(nextGroup.group)
}

View File

@ -0,0 +1,394 @@
import { createId } from '@paralleldrive/cuid2'
import { TRPCError } from '@trpc/server'
import { isDefined, omit, isNotEmpty, isInputBlock } from '@typebot.io/lib'
import {
Variable,
VariableWithValue,
Theme,
IntegrationBlockType,
GoogleAnalyticsBlock,
PixelBlock,
SessionState,
} from '@typebot.io/schemas'
import {
ChatReply,
StartParams,
StartTypebot,
startTypebotSchema,
} from '@typebot.io/schemas/features/chat/schema'
import parse, { NodeType } from 'node-html-parser'
import { env } from '@typebot.io/env'
import { parseDynamicTheme } from './parseDynamicTheme'
import { findTypebot } from './queries/findTypebot'
import { findPublicTypebot } from './queries/findPublicTypebot'
import { findResult } from './queries/findResult'
import { startBotFlow } from './startBotFlow'
import { prefillVariables } from './variables/prefillVariables'
import { deepParseVariables } from './variables/deepParseVariables'
import { injectVariablesFromExistingResult } from './variables/injectVariablesFromExistingResult'
type Props = {
startParams: StartParams
userId: string | undefined
}
export const startSession = async ({
startParams,
userId,
}: Props): Promise<ChatReply & { newSessionState: SessionState }> => {
if (!startParams)
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'StartParams are missing',
})
const typebot = await getTypebot(startParams, userId)
const prefilledVariables = startParams.prefilledVariables
? prefillVariables(typebot.variables, startParams.prefilledVariables)
: typebot.variables
const result = await getResult({
...startParams,
isPreview: startParams.isPreview || typeof startParams.typebot !== 'string',
typebotId: typebot.id,
prefilledVariables,
isRememberUserEnabled:
typebot.settings.general.rememberUser?.isEnabled ??
(isDefined(typebot.settings.general.isNewResultOnRefreshEnabled)
? !typebot.settings.general.isNewResultOnRefreshEnabled
: false),
})
const startVariables =
result && result.variables.length > 0
? injectVariablesFromExistingResult(prefilledVariables, result.variables)
: prefilledVariables
const initialState: SessionState = {
version: '2',
typebotsQueue: [
{
resultId: result?.id,
typebot: {
version: typebot.version,
id: typebot.id,
groups: typebot.groups,
edges: typebot.edges,
variables: startVariables,
},
answers: result
? result.answers.map((answer) => {
const block = typebot.groups
.flatMap((group) => group.blocks)
.find((block) => block.id === answer.blockId)
if (!block || !isInputBlock(block))
return {
key: 'unknown',
value: answer.content,
}
const key =
(block.options.variableId
? startVariables.find(
(variable) => variable.id === block.options.variableId
)?.name
: typebot.groups.find((group) =>
group.blocks.find(
(blockInGroup) => blockInGroup.id === block.id
)
)?.title) ?? 'unknown'
return {
key,
value: answer.content,
}
})
: [],
},
],
dynamicTheme: parseDynamicThemeInState(typebot.theme),
isStreamEnabled: startParams.isStreamEnabled,
typingEmulation: typebot.settings.typingEmulation,
}
if (startParams.isOnlyRegistering) {
return {
newSessionState: initialState,
typebot: {
id: typebot.id,
settings: deepParseVariables(
initialState.typebotsQueue[0].typebot.variables
)(typebot.settings),
theme: deepParseVariables(
initialState.typebotsQueue[0].typebot.variables
)(typebot.theme),
},
dynamicTheme: parseDynamicTheme(initialState),
messages: [],
}
}
const {
messages,
input,
clientSideActions: startFlowClientActions,
newSessionState,
logs,
} = await startBotFlow(initialState, startParams.startGroupId)
const clientSideActions = startFlowClientActions ?? []
const startClientSideAction = parseStartClientSideAction(typebot)
const startLogs = logs ?? []
if (isDefined(startClientSideAction)) {
if (!result) {
if ('startPropsToInject' in startClientSideAction) {
const { customHeadCode, googleAnalyticsId, pixelId, gtmId } =
startClientSideAction.startPropsToInject
let toolsList = ''
if (customHeadCode) toolsList += 'Custom head code, '
if (googleAnalyticsId) toolsList += 'Google Analytics, '
if (pixelId) toolsList += 'Pixel, '
if (gtmId) toolsList += 'Google Tag Manager, '
toolsList = toolsList.slice(0, -2)
startLogs.push({
description: `${toolsList} ${
toolsList.includes(',') ? 'are not' : 'is not'
} enabled in Preview mode`,
status: 'info',
})
}
} else {
clientSideActions.unshift(startClientSideAction)
}
}
const clientSideActionsNeedSessionId = clientSideActions?.some(
(action) => action.expectsDedicatedReply
)
if (!input && !clientSideActionsNeedSessionId)
return {
newSessionState,
messages,
clientSideActions:
clientSideActions.length > 0 ? clientSideActions : undefined,
typebot: {
id: typebot.id,
settings: deepParseVariables(
newSessionState.typebotsQueue[0].typebot.variables
)(typebot.settings),
theme: deepParseVariables(
newSessionState.typebotsQueue[0].typebot.variables
)(typebot.theme),
},
dynamicTheme: parseDynamicTheme(newSessionState),
logs: startLogs.length > 0 ? startLogs : undefined,
}
return {
newSessionState,
resultId: result?.id,
typebot: {
id: typebot.id,
settings: deepParseVariables(
newSessionState.typebotsQueue[0].typebot.variables
)(typebot.settings),
theme: deepParseVariables(
newSessionState.typebotsQueue[0].typebot.variables
)(typebot.theme),
},
messages,
input,
clientSideActions:
clientSideActions.length > 0 ? clientSideActions : undefined,
dynamicTheme: parseDynamicTheme(newSessionState),
logs: startLogs.length > 0 ? startLogs : undefined,
}
}
const getTypebot = async (
{ typebot, isPreview }: Pick<StartParams, 'typebot' | 'isPreview'>,
userId?: string
): Promise<StartTypebot> => {
if (typeof typebot !== 'string') return typebot
if (isPreview && !userId && !env.NEXT_PUBLIC_E2E_TEST)
throw new TRPCError({
code: 'UNAUTHORIZED',
message:
'You need to authenticate the request to start a bot in preview mode.',
})
const typebotQuery = isPreview
? await findTypebot({ id: typebot, userId })
: await findPublicTypebot({ publicId: typebot })
const parsedTypebot =
typebotQuery && 'typebot' in typebotQuery
? {
id: typebotQuery.typebotId,
...omit(typebotQuery.typebot, 'workspace'),
...omit(typebotQuery, 'typebot', 'typebotId'),
}
: typebotQuery
if (!parsedTypebot || parsedTypebot.isArchived)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Typebot not found',
})
const isQuarantinedOrSuspended =
typebotQuery &&
'typebot' in typebotQuery &&
(typebotQuery.typebot.workspace.isQuarantined ||
typebotQuery.typebot.workspace.isSuspended)
if (
('isClosed' in parsedTypebot && parsedTypebot.isClosed) ||
isQuarantinedOrSuspended
)
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Typebot is closed',
})
return startTypebotSchema.parse(parsedTypebot)
}
const getResult = async ({
isPreview,
resultId,
prefilledVariables,
isRememberUserEnabled,
}: Pick<StartParams, 'isPreview' | 'resultId'> & {
typebotId: string
prefilledVariables: Variable[]
isRememberUserEnabled: boolean
}) => {
if (isPreview) return
const existingResult =
resultId && isRememberUserEnabled
? await findResult({ id: resultId })
: undefined
const prefilledVariableWithValue = prefilledVariables.filter(
(prefilledVariable) => isDefined(prefilledVariable.value)
)
const updatedResult = {
variables: prefilledVariableWithValue.concat(
existingResult?.variables.filter(
(resultVariable) =>
isDefined(resultVariable.value) &&
!prefilledVariableWithValue.some(
(prefilledVariable) =>
prefilledVariable.name === resultVariable.name
)
) ?? []
) as VariableWithValue[],
}
return {
id: existingResult?.id ?? createId(),
variables: updatedResult.variables,
answers: existingResult?.answers ?? [],
}
}
const parseDynamicThemeInState = (theme: Theme) => {
const hostAvatarUrl =
theme.chat.hostAvatar?.isEnabled ?? true
? theme.chat.hostAvatar?.url
: undefined
const guestAvatarUrl =
theme.chat.guestAvatar?.isEnabled ?? false
? theme.chat.guestAvatar?.url
: undefined
if (!hostAvatarUrl?.startsWith('{{') && !guestAvatarUrl?.startsWith('{{'))
return
return {
hostAvatarUrl: hostAvatarUrl?.startsWith('{{') ? hostAvatarUrl : undefined,
guestAvatarUrl: guestAvatarUrl?.startsWith('{{')
? guestAvatarUrl
: undefined,
}
}
const parseStartClientSideAction = (
typebot: StartTypebot
): NonNullable<ChatReply['clientSideActions']>[number] | undefined => {
const blocks = typebot.groups.flatMap((group) => group.blocks)
const startPropsToInject = {
customHeadCode: isNotEmpty(typebot.settings.metadata.customHeadCode)
? parseHeadCode(typebot.settings.metadata.customHeadCode)
: undefined,
gtmId: typebot.settings.metadata.googleTagManagerId,
googleAnalyticsId: (
blocks.find(
(block) =>
block.type === IntegrationBlockType.GOOGLE_ANALYTICS &&
block.options.trackingId
) as GoogleAnalyticsBlock | undefined
)?.options.trackingId,
pixelId: (
blocks.find(
(block) =>
block.type === IntegrationBlockType.PIXEL &&
block.options.pixelId &&
block.options.isInitSkip !== true
) as PixelBlock | undefined
)?.options.pixelId,
}
if (
!startPropsToInject.customHeadCode &&
!startPropsToInject.gtmId &&
!startPropsToInject.googleAnalyticsId &&
!startPropsToInject.pixelId
)
return
return {
startPropsToInject,
}
}
const parseHeadCode = (code: string) => {
code = injectTryCatch(code)
return parse(code)
.childNodes.filter((child) => child.nodeType !== NodeType.TEXT_NODE)
.join('\n')
}
const injectTryCatch = (headCode: string) => {
const scriptTagRegex = /<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi
const scriptTags = headCode.match(scriptTagRegex)
if (scriptTags) {
scriptTags.forEach(function (tag) {
const wrappedTag = tag.replace(
/(<script\b[^>]*>)([\s\S]*?)(<\/script>)/gi,
function (_, openingTag, content, closingTag) {
if (!isValidJsSyntax(content)) return ''
return `${openingTag}
try {
${content}
} catch (e) {
console.warn(e);
}
${closingTag}`
}
)
headCode = headCode.replace(tag, wrappedTag)
})
}
return headCode
}
const isValidJsSyntax = (snippet: string): boolean => {
try {
new Function(snippet)
return true
} catch (err) {
return false
}
}

View File

@ -0,0 +1,9 @@
{
"extends": "@typebot.io/tsconfig/base.json",
"include": ["**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"],
"compilerOptions": {
"jsx": "preserve",
"lib": ["ES2021"]
}
}

View File

@ -0,0 +1,18 @@
import { ChatReply, SessionState } from '@typebot.io/schemas'
export type EdgeId = string
export type ExecuteLogicResponse = {
outgoingEdgeId: EdgeId | undefined
newSessionState?: SessionState
} & Pick<ChatReply, 'clientSideActions' | 'logs'>
export type ExecuteIntegrationResponse = {
outgoingEdgeId: EdgeId | undefined
newSessionState?: SessionState
} & Pick<ChatReply, 'clientSideActions' | 'logs'>
export type ParsedReply =
| { status: 'success'; reply: string }
| { status: 'fail' }
| { status: 'skip' }

View File

@ -0,0 +1,65 @@
import { Variable } from '@typebot.io/schemas'
import {
defaultParseVariablesOptions,
parseVariables,
ParseVariablesOptions,
} from './parseVariables'
import { parseGuessedTypeFromString } from './parseGuessedTypeFromString'
type DeepParseOptions = {
guessCorrectTypes?: boolean
removeEmptyStrings?: boolean
}
export const deepParseVariables =
(
variables: Variable[],
deepParseOptions: DeepParseOptions = {
guessCorrectTypes: false,
removeEmptyStrings: false,
},
parseVariablesOptions: ParseVariablesOptions = defaultParseVariablesOptions
) =>
<T extends Record<string, unknown>>(object: T): T =>
Object.keys(object).reduce<T>((newObj, key) => {
const currentValue = object[key]
if (typeof currentValue === 'string') {
const parsedVariable = parseVariables(
variables,
parseVariablesOptions
)(currentValue)
if (deepParseOptions.removeEmptyStrings && parsedVariable === '')
return newObj
return {
...newObj,
[key]: deepParseOptions.guessCorrectTypes
? parseGuessedTypeFromString(parsedVariable)
: parsedVariable,
}
}
if (currentValue instanceof Object && currentValue.constructor === Object)
return {
...newObj,
[key]: deepParseVariables(
variables,
deepParseOptions,
parseVariablesOptions
)(currentValue as Record<string, unknown>),
}
if (currentValue instanceof Array)
return {
...newObj,
[key]: currentValue.map(
deepParseVariables(
variables,
deepParseOptions,
parseVariablesOptions
)
),
}
return { ...newObj, [key]: currentValue }
}, {} as T)

View File

@ -0,0 +1,19 @@
import { Variable } from '@typebot.io/schemas'
export const extractVariablesFromText =
(variables: Variable[]) =>
(text: string): Variable[] => {
const matches = [...text.matchAll(/\{\{(.*?)\}\}/g)]
return matches.reduce<Variable[]>((acc, match) => {
const variableName = match[1]
const variable = variables.find(
(variable) => variable.name === variableName
)
if (
!variable ||
acc.find((accVariable) => accVariable.id === variable.id)
)
return acc
return [...acc, variable]
}, [])
}

View File

@ -0,0 +1,12 @@
import { Variable } from '@typebot.io/schemas'
export const findUniqueVariableValue =
(variables: Variable[]) =>
(value: string | undefined): Variable['value'] => {
if (!value || !value.startsWith('{{') || !value.endsWith('}}')) return null
const variableName = value.slice(2, -2)
const variable = variables.find(
(variable) => variable.name === variableName
)
return variable?.value ?? null
}

View File

@ -0,0 +1 @@
export const hasVariable = (str: string): boolean => /\{\{(.*?)\}\}/g.test(str)

View File

@ -0,0 +1,17 @@
import { Result, Variable } from '@typebot.io/schemas'
export const injectVariablesFromExistingResult = (
variables: Variable[],
resultVariables: Result['variables']
): Variable[] =>
variables.map((variable) => {
const resultVariable = resultVariables.find(
(resultVariable) =>
resultVariable.name === variable.name && !variable.value
)
if (!resultVariable) return variable
return {
...variable,
value: resultVariable.value,
}
})

View File

@ -0,0 +1,12 @@
export const parseGuessedTypeFromString = (value: string): unknown => {
if (value === 'undefined') return undefined
return safeJsonParse(value)
}
const safeJsonParse = (value: string): unknown => {
try {
return JSON.parse(value)
} catch {
return value
}
}

View File

@ -0,0 +1,22 @@
import { Variable } from '@typebot.io/schemas'
export const parseGuessedValueType = (
value: Variable['value']
): string | (string | null)[] | boolean | number | null | undefined => {
if (value === null) return null
if (value === undefined) return undefined
if (typeof value !== 'string') return value
const isStartingWithZero =
value.startsWith('0') && !value.startsWith('0.') && value.length > 1
if (typeof value === 'string' && isStartingWithZero) return value
const isStartingWithPlus = value.startsWith('+')
if (typeof value === 'string' && isStartingWithPlus) return value
if (typeof value === 'number') return value
if (value === 'true') return true
if (value === 'false') return false
if (value === 'null') return null
if (value === 'undefined') return undefined
// isNaN works with strings
if (isNaN(value as unknown as number)) return value
return Number(value)
}

View File

@ -0,0 +1,12 @@
import { Variable } from '@typebot.io/schemas'
import { parseGuessedValueType } from './parseGuessedValueType'
import { parseVariables } from './parseVariables'
export const parseVariableNumber =
(variables: Variable[]) =>
(input: number | `{{${string}}}` | undefined): number | undefined => {
if (typeof input === 'number' || input === undefined) return input
const parsedInput = parseGuessedValueType(parseVariables(variables)(input))
if (typeof parsedInput !== 'number') return undefined
return parsedInput
}

View File

@ -0,0 +1,70 @@
import { safeStringify } from '@typebot.io/lib/safeStringify'
import { isDefined } from '@typebot.io/lib/utils'
import { Variable, VariableWithValue } from '@typebot.io/schemas'
export type ParseVariablesOptions = {
fieldToParse?: 'value' | 'id'
isInsideJson?: boolean
takeLatestIfList?: boolean
isInsideHtml?: boolean
}
export const defaultParseVariablesOptions: ParseVariablesOptions = {
fieldToParse: 'value',
isInsideJson: false,
takeLatestIfList: false,
isInsideHtml: false,
}
export const parseVariables =
(
variables: Variable[],
options: ParseVariablesOptions = defaultParseVariablesOptions
) =>
(text: string | undefined): string => {
if (!text || text === '') return ''
// Capture {{variable}} and ${{{variable}}} (variables in template litterals)
const pattern = /\{\{([^{}]+)\}\}|(\$)\{\{([^{}]+)\}\}/g
return text.replace(
pattern,
(_full, nameInCurlyBraces, _dollarSign, nameInTemplateLitteral) => {
const dollarSign = (_dollarSign ?? '') as string
const matchedVarName = nameInCurlyBraces ?? nameInTemplateLitteral
const variable = variables.find((variable) => {
return (
matchedVarName === variable.name &&
(options.fieldToParse === 'id' || isDefined(variable.value))
)
}) as VariableWithValue | undefined
if (!variable) return dollarSign + ''
if (options.fieldToParse === 'id') return dollarSign + variable.id
const { value } = variable
if (options.isInsideJson)
return dollarSign + parseVariableValueInJson(value)
const parsedValue =
dollarSign +
safeStringify(
options.takeLatestIfList && Array.isArray(value)
? value[value.length - 1]
: value
)
if (!parsedValue) return dollarSign + ''
if (options.isInsideHtml) return parseVariableValueInHtml(parsedValue)
return parsedValue
}
)
}
const parseVariableValueInJson = (value: VariableWithValue['value']) => {
const stringifiedValue = JSON.stringify(value)
if (typeof value === 'string') return stringifiedValue.slice(1, -1)
return stringifiedValue
}
const parseVariableValueInHtml = (
value: VariableWithValue['value']
): string => {
if (typeof value === 'string')
return value.replace(/</g, '&lt;').replace(/>/g, '&gt;')
return JSON.stringify(value).replace(/</g, '&lt;').replace(/>/g, '&gt;')
}

View File

@ -0,0 +1,15 @@
import { safeStringify } from '@typebot.io/lib/safeStringify'
import { StartParams, Variable } from '@typebot.io/schemas'
export const prefillVariables = (
variables: Variable[],
prefilledVariables: NonNullable<StartParams['prefilledVariables']>
): Variable[] =>
variables.map((variable) => {
const prefilledVariable = prefilledVariables[variable.name]
if (!prefilledVariable) return variable
return {
...variable,
value: safeStringify(prefilledVariable),
}
})

View File

@ -0,0 +1,26 @@
import { isNotDefined } from '@typebot.io/lib/utils'
import { Variable, VariableWithValue } from '@typebot.io/schemas'
export const transformStringVariablesToList =
(variables: Variable[]) =>
(variableIds: string[]): VariableWithValue[] => {
const newVariables = variables.reduce<VariableWithValue[]>(
(variables, variable) => {
if (
!variableIds.includes(variable.id) ||
isNotDefined(variable.value) ||
typeof variable.value !== 'string'
)
return variables
return [
...variables,
{
...variable,
value: [variable.value],
},
]
},
[]
)
return newVariables
}

View File

@ -0,0 +1,45 @@
import { safeStringify } from '@typebot.io/lib/safeStringify'
import {
SessionState,
VariableWithUnknowValue,
Variable,
} from '@typebot.io/schemas'
export const updateVariablesInSession =
(state: SessionState) =>
(newVariables: VariableWithUnknowValue[]): SessionState => ({
...state,
typebotsQueue: state.typebotsQueue.map((typebotInQueue, index) =>
index === 0
? {
...typebotInQueue,
typebot: {
...typebotInQueue.typebot,
variables: updateTypebotVariables(typebotInQueue.typebot)(
newVariables
),
},
}
: typebotInQueue
),
})
const updateTypebotVariables =
(typebot: { variables: Variable[] }) =>
(newVariables: VariableWithUnknowValue[]): Variable[] => {
const serializedNewVariables = newVariables.map((variable) => ({
...variable,
value: Array.isArray(variable.value)
? variable.value.map(safeStringify)
: safeStringify(variable.value),
}))
return [
...typebot.variables.filter((existingVariable) =>
serializedNewVariables.every(
(newVariable) => existingVariable.id !== newVariable.id
)
),
...serializedNewVariables,
]
}

View File

@ -0,0 +1,138 @@
import {
BubbleBlockType,
ButtonItem,
ChatReply,
InputBlockType,
} from '@typebot.io/schemas'
import { WhatsAppSendingMessage } from '@typebot.io/schemas/features/whatsapp'
import { convertRichTextToWhatsAppText } from './convertRichTextToWhatsAppText'
import { isDefined, isEmpty } from '@typebot.io/lib/utils'
export const convertInputToWhatsAppMessages = (
input: NonNullable<ChatReply['input']>,
lastMessage: ChatReply['messages'][number] | undefined
): WhatsAppSendingMessage[] => {
const lastMessageText =
lastMessage?.type === BubbleBlockType.TEXT
? convertRichTextToWhatsAppText(lastMessage.content.richText)
: undefined
switch (input.type) {
case InputBlockType.DATE:
case InputBlockType.EMAIL:
case InputBlockType.FILE:
case InputBlockType.NUMBER:
case InputBlockType.PHONE:
case InputBlockType.URL:
case InputBlockType.PAYMENT:
case InputBlockType.RATING:
case InputBlockType.TEXT:
return []
case InputBlockType.PICTURE_CHOICE: {
if (input.options.isMultipleChoice)
return input.items.flatMap((item, idx) => {
let bodyText = ''
if (item.title) bodyText += `*${item.title}*`
if (item.description) {
if (item.title) bodyText += '\n\n'
bodyText += item.description
}
const imageMessage = item.pictureSrc
? ({
type: 'image',
image: {
link: item.pictureSrc ?? '',
},
} as const)
: undefined
const textMessage = {
type: 'text',
text: {
body: `${idx + 1}. ${bodyText}`,
},
} as const
return imageMessage ? [imageMessage, textMessage] : textMessage
})
return input.items.map((item) => {
let bodyText = ''
if (item.title) bodyText += `*${item.title}*`
if (item.description) {
if (item.title) bodyText += '\n\n'
bodyText += item.description
}
return {
type: 'interactive',
interactive: {
type: 'button',
header: item.pictureSrc
? {
type: 'image',
image: {
link: item.pictureSrc,
},
}
: undefined,
body: isEmpty(bodyText) ? undefined : { text: bodyText },
action: {
buttons: [
{
type: 'reply',
reply: {
id: item.id,
title: 'Select',
},
},
],
},
},
}
})
}
case InputBlockType.CHOICE: {
if (input.options.isMultipleChoice)
return [
{
type: 'text',
text: {
body:
`${lastMessageText}\n\n` +
input.items
.map((item, idx) => `${idx + 1}. ${item.content}`)
.join('\n'),
},
},
]
const items = groupArrayByArraySize(
input.items.filter((item) => isDefined(item.content)),
3
) as ButtonItem[][]
return items.map((items, idx) => ({
type: 'interactive',
interactive: {
type: 'button',
body: {
text: idx === 0 ? lastMessageText ?? '...' : '...',
},
action: {
buttons: items.map((item) => ({
type: 'reply',
reply: {
id: item.id,
title: trimTextTo20Chars(item.content as string),
},
})),
},
},
}))
}
}
}
const trimTextTo20Chars = (text: string): string =>
text.length > 20 ? `${text.slice(0, 18)}..` : text
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const groupArrayByArraySize = (arr: any[], n: number) =>
arr.reduce(
(r, e, i) => (i % n ? r[r.length - 1].push(e) : r.push([e])) && r,
[]
)

View File

@ -0,0 +1,84 @@
import {
BubbleBlockType,
ChatReply,
VideoBubbleContentType,
} from '@typebot.io/schemas'
import { WhatsAppSendingMessage } from '@typebot.io/schemas/features/whatsapp'
import { convertRichTextToWhatsAppText } from './convertRichTextToWhatsAppText'
import { isSvgSrc } from '@typebot.io/lib/utils'
const mp4HttpsUrlRegex = /^https:\/\/.*\.mp4$/
export const convertMessageToWhatsAppMessage = (
message: ChatReply['messages'][number]
): WhatsAppSendingMessage | undefined => {
switch (message.type) {
case BubbleBlockType.TEXT: {
if (!message.content.richText || message.content.richText.length === 0)
return
return {
type: 'text',
text: {
body: convertRichTextToWhatsAppText(message.content.richText),
},
}
}
case BubbleBlockType.IMAGE: {
if (!message.content.url || isImageUrlNotCompatible(message.content.url))
return
return {
type: 'image',
image: {
link: message.content.url,
},
}
}
case BubbleBlockType.AUDIO: {
if (!message.content.url) return
return {
type: 'audio',
audio: {
link: message.content.url,
},
}
}
case BubbleBlockType.VIDEO: {
if (
!message.content.url ||
(message.content.type !== VideoBubbleContentType.URL &&
isVideoUrlNotCompatible(message.content.url))
)
return
return {
type: 'video',
video: {
link: message.content.url,
},
}
}
case BubbleBlockType.EMBED: {
if (!message.content.url) return
return {
type: 'text',
text: {
body: message.content.url,
},
preview_url: true,
}
}
}
}
export const isImageUrlNotCompatible = (url: string) =>
!isHttpUrl(url) || isGifFileUrl(url) || isSvgSrc(url)
export const isVideoUrlNotCompatible = (url: string) =>
!mp4HttpsUrlRegex.test(url)
export const isHttpUrl = (text: string) =>
text.startsWith('http://') || text.startsWith('https://')
export const isGifFileUrl = (url: string) => {
const urlWithoutQueryParams = url.split('?')[0]
return urlWithoutQueryParams.endsWith('.gif')
}

View File

@ -0,0 +1,9 @@
import { TElement } from '@udecode/plate-common'
import { serialize } from 'remark-slate'
export const convertRichTextToWhatsAppText = (richText: TElement[]): string =>
richText
.map((chunk) =>
serialize(chunk)?.replaceAll('**', '*').replaceAll('&amp;#39;', "'")
)
.join('')

View File

@ -0,0 +1,46 @@
import got from 'got'
import { TRPCError } from '@trpc/server'
import { uploadFileToBucket } from '@typebot.io/lib/s3/uploadFileToBucket'
type Props = {
mediaId: string
systemUserToken: string
downloadPath: string
}
export const downloadMedia = async ({
mediaId,
systemUserToken,
downloadPath,
}: Props) => {
const { body } = await got.get({
url: `https://graph.facebook.com/v17.0/${mediaId}`,
headers: {
Authorization: `Bearer ${systemUserToken}`,
},
})
const parsedBody = JSON.parse(body) as { url: string; mime_type: string }
if (!parsedBody.url)
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Request to Facebook failed. Could not find media url.',
cause: body,
})
const streamBuffer = await got(parsedBody.url, {
headers: {
Authorization: `Bearer ${systemUserToken}`,
},
}).buffer()
const typebotUrl = await uploadFileToBucket({
fileName: `public/${downloadPath}/${mediaId}`,
file: streamBuffer,
mimeType: parsedBody.mime_type,
})
await got.delete({
url: `https://graph.facebook.com/v17.0/${mediaId}`,
headers: {
Authorization: `Bearer ${systemUserToken}`,
},
})
return typebotUrl
}

View File

@ -0,0 +1,192 @@
import { SessionState } from '@typebot.io/schemas'
import {
WhatsAppCredentials,
WhatsAppIncomingMessage,
} from '@typebot.io/schemas/features/whatsapp'
import { env } from '@typebot.io/env'
import { sendChatReplyToWhatsApp } from './sendChatReplyToWhatsApp'
import { startWhatsAppSession } from './startWhatsAppSession'
import { downloadMedia } from './downloadMedia'
import { getSession } from '../queries/getSession'
import { continueBotFlow } from '../continueBotFlow'
import { decrypt } from '@typebot.io/lib/api'
import { saveStateToDatabase } from '../saveStateToDatabase'
import prisma from '@typebot.io/lib/prisma'
export const resumeWhatsAppFlow = async ({
receivedMessage,
sessionId,
workspaceId,
phoneNumberId,
contact,
}: {
receivedMessage: WhatsAppIncomingMessage
sessionId: string
phoneNumberId: string
workspaceId?: string
contact: NonNullable<SessionState['whatsApp']>['contact']
}) => {
const messageSendDate = new Date(Number(receivedMessage.timestamp) * 1000)
const messageSentBefore3MinutesAgo =
messageSendDate.getTime() < Date.now() - 180000
if (messageSentBefore3MinutesAgo) {
console.log('Message is too old', messageSendDate.getTime())
return {
message: 'Message received',
}
}
const session = await getSession(sessionId)
const initialCredentials = session
? await getCredentials(phoneNumberId)(session.state)
: undefined
const { typebot, resultId } = session?.state.typebotsQueue[0] ?? {}
const messageContent = await getIncomingMessageContent({
message: receivedMessage,
systemUserToken: initialCredentials?.systemUserAccessToken,
downloadPath:
typebot && resultId
? `typebots/${typebot.id}/results/${resultId}`
: undefined,
})
const isPreview = workspaceId === undefined
const sessionState =
isPreview && session?.state
? ({
...session?.state,
whatsApp: {
contact,
},
} satisfies SessionState)
: session?.state
const resumeResponse = sessionState
? await continueBotFlow(sessionState)(messageContent)
: workspaceId
? await startWhatsAppSession({
message: receivedMessage,
sessionId,
workspaceId,
phoneNumberId,
contact,
})
: undefined
if (!resumeResponse) {
console.error('Could not find or create session', sessionId)
return {
message: 'Message received',
}
}
const credentials =
initialCredentials ??
(await getCredentials(phoneNumberId)(resumeResponse.newSessionState))
if (!credentials) {
console.error('Could not find credentials')
return {
message: 'Message received',
}
}
const { input, logs, newSessionState, messages, clientSideActions } =
resumeResponse
await sendChatReplyToWhatsApp({
to: receivedMessage.from,
messages,
input,
typingEmulation: newSessionState.typingEmulation,
clientSideActions,
credentials,
})
await saveStateToDatabase({
isFirstSave: !session,
clientSideActions: [],
input,
logs,
session: {
id: sessionId,
state: {
...newSessionState,
currentBlock: !input ? undefined : newSessionState.currentBlock,
},
},
})
return {
message: 'Message received',
}
}
const getIncomingMessageContent = async ({
message,
systemUserToken,
downloadPath,
}: {
message: WhatsAppIncomingMessage
systemUserToken: string | undefined
downloadPath?: string
}): Promise<string | undefined> => {
switch (message.type) {
case 'text':
return message.text.body
case 'button':
return message.button.text
case 'interactive': {
return message.interactive.button_reply.id
}
case 'document':
case 'audio':
return
case 'video':
case 'image':
if (!systemUserToken || !downloadPath) return ''
return downloadMedia({
mediaId: 'video' in message ? message.video.id : message.image.id,
systemUserToken,
downloadPath,
})
}
}
const getCredentials =
(phoneNumberId: string) =>
async (
state: SessionState
): Promise<WhatsAppCredentials['data'] | undefined> => {
const isPreview = !state.typebotsQueue[0].resultId
if (isPreview) {
if (!env.META_SYSTEM_USER_TOKEN) return
return {
systemUserAccessToken: env.META_SYSTEM_USER_TOKEN,
phoneNumberId,
}
}
if (!state.whatsApp) return
const credentials = await prisma.credentials.findUnique({
where: {
id: state.whatsApp.credentialsId,
},
select: {
data: true,
iv: true,
},
})
if (!credentials) return
const data = (await decrypt(
credentials.data,
credentials.iv
)) as WhatsAppCredentials['data']
return {
systemUserAccessToken: data.systemUserAccessToken,
phoneNumberId,
}
}

View File

@ -0,0 +1,162 @@
import {
ChatReply,
InputBlockType,
SessionState,
Settings,
} from '@typebot.io/schemas'
import {
WhatsAppCredentials,
WhatsAppSendingMessage,
} from '@typebot.io/schemas/features/whatsapp'
import { convertMessageToWhatsAppMessage } from './convertMessageToWhatsAppMessage'
import { sendWhatsAppMessage } from './sendWhatsAppMessage'
import { captureException } from '@sentry/nextjs'
import { HTTPError } from 'got'
import { convertInputToWhatsAppMessages } from './convertInputToWhatsAppMessage'
import { isNotDefined } from '@typebot.io/lib/utils'
import { computeTypingDuration } from '../computeTypingDuration'
// 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
type Props = {
to: string
typingEmulation: SessionState['typingEmulation']
credentials: WhatsAppCredentials['data']
} & Pick<ChatReply, 'messages' | 'input' | 'clientSideActions'>
export const sendChatReplyToWhatsApp = async ({
to,
typingEmulation,
messages,
input,
clientSideActions,
credentials,
}: Props) => {
const messagesBeforeInput = isLastMessageIncludedInInput(input)
? messages.slice(0, -1)
: messages
const sentMessages: WhatsAppSendingMessage[] = []
for (const message of messagesBeforeInput) {
const whatsAppMessage = convertMessageToWhatsAppMessage(message)
if (isNotDefined(whatsAppMessage)) continue
const lastSentMessageIsMedia = ['audio', 'video', 'image'].includes(
sentMessages.at(-1)?.type ?? ''
)
const typingDuration = lastSentMessageIsMedia
? messageAfterMediaTimeout
: getTypingDuration({
message: whatsAppMessage,
typingEmulation,
})
if (typingDuration)
await new Promise((resolve) => setTimeout(resolve, typingDuration))
try {
await sendWhatsAppMessage({
to,
message: whatsAppMessage,
credentials,
})
sentMessages.push(whatsAppMessage)
} catch (err) {
captureException(err, { extra: { message } })
console.log('Failed to send message:', JSON.stringify(message, null, 2))
if (err instanceof HTTPError)
console.log('HTTPError', err.response.statusCode, err.response.body)
}
}
if (clientSideActions)
for (const clientSideAction of clientSideActions) {
if ('redirect' in clientSideAction && clientSideAction.redirect.url) {
const message = {
type: 'text',
text: {
body: clientSideAction.redirect.url,
preview_url: true,
},
} satisfies WhatsAppSendingMessage
try {
await sendWhatsAppMessage({
to,
message,
credentials,
})
} catch (err) {
captureException(err, { extra: { message } })
console.log(
'Failed to send message:',
JSON.stringify(message, null, 2)
)
if (err instanceof HTTPError)
console.log('HTTPError', err.response.statusCode, err.response.body)
}
}
}
if (input) {
const inputWhatsAppMessages = convertInputToWhatsAppMessages(
input,
messages.at(-1)
)
for (const message of inputWhatsAppMessages) {
try {
const lastSentMessageIsMedia = ['audio', 'video', 'image'].includes(
sentMessages.at(-1)?.type ?? ''
)
const typingDuration = lastSentMessageIsMedia
? messageAfterMediaTimeout
: getTypingDuration({
message,
typingEmulation,
})
if (typingDuration)
await new Promise((resolve) => setTimeout(resolve, typingDuration))
await sendWhatsAppMessage({
to,
message,
credentials,
})
} catch (err) {
captureException(err, { extra: { message } })
console.log('Failed to send message:', JSON.stringify(message, null, 2))
if (err instanceof HTTPError)
console.log('HTTPError', err.response.statusCode, err.response.body)
}
}
}
}
const getTypingDuration = ({
message,
typingEmulation,
}: {
message: WhatsAppSendingMessage
typingEmulation?: Settings['typingEmulation']
}): number | undefined => {
switch (message.type) {
case 'text':
return computeTypingDuration({
bubbleContent: message.text.body,
typingSettings: typingEmulation,
})
case 'interactive':
if (!message.interactive.body?.text) return
return computeTypingDuration({
bubbleContent: message.interactive.body?.text ?? '',
typingSettings: typingEmulation,
})
case 'audio':
case 'video':
case 'image':
case 'template':
return
}
}
const isLastMessageIncludedInInput = (input: ChatReply['input']): boolean => {
if (isNotDefined(input)) return false
return input.type === InputBlockType.CHOICE
}

View File

@ -0,0 +1,28 @@
import got from 'got'
import {
WhatsAppCredentials,
WhatsAppSendingMessage,
} from '@typebot.io/schemas/features/whatsapp'
type Props = {
to: string
message: WhatsAppSendingMessage
credentials: WhatsAppCredentials['data']
}
export const sendWhatsAppMessage = async ({
to,
message,
credentials,
}: Props) =>
got.post({
url: `https://graph.facebook.com/v17.0/${credentials.phoneNumberId}/messages`,
headers: {
Authorization: `Bearer ${credentials.systemUserAccessToken}`,
},
json: {
messaging_product: 'whatsapp',
to,
...message,
},
})

View File

@ -0,0 +1,196 @@
import prisma from '@typebot.io/lib/prisma'
import {
ChatReply,
ComparisonOperators,
LogicalOperator,
PublicTypebot,
SessionState,
Settings,
Typebot,
} from '@typebot.io/schemas'
import {
WhatsAppCredentials,
WhatsAppIncomingMessage,
} from '@typebot.io/schemas/features/whatsapp'
import { isNotDefined } from '@typebot.io/lib/utils'
import { decrypt } from '@typebot.io/lib/api/encryption'
import { startSession } from '../startSession'
type Props = {
message: WhatsAppIncomingMessage
sessionId: string
workspaceId?: string
phoneNumberId: string
contact: NonNullable<SessionState['whatsApp']>['contact']
}
export const startWhatsAppSession = async ({
message,
workspaceId,
phoneNumberId,
contact,
}: Props): Promise<
| (ChatReply & {
newSessionState: SessionState
})
| undefined
> => {
const publicTypebotsWithWhatsAppEnabled =
(await prisma.publicTypebot.findMany({
where: {
typebot: { workspaceId, whatsAppPhoneNumberId: phoneNumberId },
},
select: {
settings: true,
typebot: {
select: {
publicId: true,
},
},
},
})) as (Pick<PublicTypebot, 'settings'> & {
typebot: Pick<Typebot, 'publicId'>
})[]
const botsWithWhatsAppEnabled = publicTypebotsWithWhatsAppEnabled.filter(
(publicTypebot) =>
publicTypebot.typebot.publicId &&
publicTypebot.settings.whatsApp?.credentialsId
)
const publicTypebot =
botsWithWhatsAppEnabled.find(
(publicTypebot) =>
publicTypebot.settings.whatsApp?.startCondition &&
messageMatchStartCondition(
getIncomingMessageText(message),
publicTypebot.settings.whatsApp?.startCondition
)
) ?? botsWithWhatsAppEnabled[0]
if (isNotDefined(publicTypebot)) return
const encryptedCredentials = await prisma.credentials.findUnique({
where: {
id: publicTypebot.settings.whatsApp?.credentialsId,
},
})
if (!encryptedCredentials) return
const credentials = (await decrypt(
encryptedCredentials?.data,
encryptedCredentials?.iv
)) as WhatsAppCredentials['data']
if (credentials.phoneNumberId !== phoneNumberId) return
const session = await startSession({
startParams: {
typebot: publicTypebot.typebot.publicId as string,
},
userId: undefined,
})
return {
...session,
newSessionState: {
...session.newSessionState,
whatsApp: {
contact,
credentialsId: publicTypebot?.settings.whatsApp
?.credentialsId as string,
},
},
}
}
export const messageMatchStartCondition = (
message: string,
startCondition: NonNullable<Settings['whatsApp']>['startCondition']
) => {
if (!startCondition) return true
return startCondition.logicalOperator === LogicalOperator.AND
? startCondition.comparisons.every((comparison) =>
matchComparison(
message,
comparison.comparisonOperator,
comparison.value
)
)
: startCondition.comparisons.some((comparison) =>
matchComparison(
message,
comparison.comparisonOperator,
comparison.value
)
)
}
const matchComparison = (
inputValue: string,
comparisonOperator?: ComparisonOperators,
value?: string
): boolean | undefined => {
if (!comparisonOperator) return false
switch (comparisonOperator) {
case ComparisonOperators.CONTAINS: {
if (!value) return false
return inputValue
.trim()
.toLowerCase()
.includes(value.trim().toLowerCase())
}
case ComparisonOperators.EQUAL: {
return inputValue === value
}
case ComparisonOperators.NOT_EQUAL: {
return inputValue !== value
}
case ComparisonOperators.GREATER: {
if (!value) return false
return parseFloat(inputValue) > parseFloat(value)
}
case ComparisonOperators.LESS: {
if (!value) return false
return parseFloat(inputValue) < parseFloat(value)
}
case ComparisonOperators.IS_SET: {
return inputValue.length > 0
}
case ComparisonOperators.IS_EMPTY: {
return inputValue.length === 0
}
case ComparisonOperators.STARTS_WITH: {
if (!value) return false
return inputValue.toLowerCase().startsWith(value.toLowerCase())
}
case ComparisonOperators.ENDS_WITH: {
if (!value) return false
return inputValue.toLowerCase().endsWith(value.toLowerCase())
}
case ComparisonOperators.NOT_CONTAINS: {
if (!value) return false
return !inputValue
.trim()
.toLowerCase()
.includes(value.trim().toLowerCase())
}
}
}
const getIncomingMessageText = (message: WhatsAppIncomingMessage): string => {
switch (message.type) {
case 'text':
return message.text.body
case 'button':
return message.button.text
case 'interactive': {
return message.interactive.button_reply.title
}
case 'video':
case 'document':
case 'audio':
case 'image': {
return ''
}
}
}