2
0

♻️ Export bot-engine code into its own package

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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