♻️ Export bot-engine code into its own package
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 ?? '',
|
||||
}
|
||||
}
|
||||
48
packages/bot-engine/blocks/inputs/date/parseDateInput.ts
Normal file
48
packages/bot-engine/blocks/inputs/date/parseDateInput.ts
Normal 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
|
||||
}
|
||||
51
packages/bot-engine/blocks/inputs/date/parseDateReply.ts
Normal file
51
packages/bot-engine/blocks/inputs/date/parseDateReply.ts
Normal 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)
|
||||
4
packages/bot-engine/blocks/inputs/email/validateEmail.ts
Normal file
4
packages/bot-engine/blocks/inputs/email/validateEmail.ts
Normal 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)
|
||||
@@ -0,0 +1 @@
|
||||
export const validateNumber = (inputValue: string) => !isNaN(Number(inputValue))
|
||||
@@ -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)
|
||||
16
packages/bot-engine/blocks/inputs/phone/formatPhoneNumber.ts
Normal file
16
packages/bot-engine/blocks/inputs/phone/formatPhoneNumber.ts
Normal 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
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
@@ -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 ?? '',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { RatingInputBlock } from '@typebot.io/schemas'
|
||||
|
||||
export const validateRatingReply = (reply: string, block: RatingInputBlock) =>
|
||||
Number(reply) <= block.options.length
|
||||
4
packages/bot-engine/blocks/inputs/url/validateUrl.ts
Normal file
4
packages/bot-engine/blocks/inputs/url/validateUrl.ts
Normal 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)
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
108
packages/bot-engine/blocks/integrations/googleSheets/getRow.ts
Normal file
108
packages/bot-engine/blocks/integrations/googleSheets/getRow.ts
Normal 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)]]
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
})
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}, {})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
@@ -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 />')
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
16
packages/bot-engine/blocks/logic/abTest/executeAbTest.ts
Normal file
16
packages/bot-engine/blocks/logic/abTest/executeAbTest.ts
Normal 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 }
|
||||
}
|
||||
170
packages/bot-engine/blocks/logic/condition/executeCondition.ts
Normal file
170
packages/bot-engine/blocks/logic/condition/executeCondition.ts
Normal 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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
29
packages/bot-engine/blocks/logic/jump/executeJumpBlock.ts
Normal file
29
packages/bot-engine/blocks/logic/jump/executeJumpBlock.ts
Normal 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 }
|
||||
}
|
||||
21
packages/bot-engine/blocks/logic/redirect/executeRedirect.ts
Normal file
21
packages/bot-engine/blocks/logic/redirect/executeRedirect.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
47
packages/bot-engine/blocks/logic/script/executeScript.ts
Normal file
47
packages/bot-engine/blocks/logic/script/executeScript.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
32
packages/bot-engine/blocks/logic/wait/executeWait.ts
Normal file
32
packages/bot-engine/blocks/logic/wait/executeWait.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user