⚗️ Implement chat API

This commit is contained in:
Baptiste Arnaud
2022-11-29 10:02:40 +01:00
parent 49ba434350
commit bf0d0c2475
122 changed files with 5075 additions and 292 deletions

View File

@@ -0,0 +1 @@
export * from './utils'

View File

@@ -0,0 +1 @@
export * from './validateButtonInput'

View File

@@ -0,0 +1,6 @@
import { ChoiceInputBlock } from 'models'
export const validateButtonInput = (
buttonBlock: ChoiceInputBlock,
input: string
) => buttonBlock.items.some((item) => item.content === input)

View File

@@ -0,0 +1 @@
export * from './utils'

View File

@@ -0,0 +1 @@
export * from './parseReadableDate'

View File

@@ -0,0 +1,26 @@
export const parseReadableDate = ({
from,
to,
hasTime,
isRange,
}: {
from: string
to: string
hasTime?: boolean
isRange?: boolean
}) => {
const currentLocale = window.navigator.language
const formatOptions: Intl.DateTimeFormatOptions = {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: hasTime ? '2-digit' : undefined,
minute: hasTime ? '2-digit' : undefined,
}
const fromReadable = new Date(from).toLocaleString(
currentLocale,
formatOptions
)
const toReadable = new Date(to).toLocaleString(currentLocale, formatOptions)
return `${fromReadable}${isRange ? ` to ${toReadable}` : ''}`
}

View File

@@ -0,0 +1 @@
export * from './utils'

View File

@@ -0,0 +1 @@
export * from './validateEmail'

View File

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

View File

@@ -0,0 +1,119 @@
import test, { expect } from '@playwright/test'
import cuid from 'cuid'
import { parse } from 'papaparse'
import { readFileSync } from 'fs'
import { isDefined } from 'utils'
import { importTypebotInDatabase } from 'utils/playwright/databaseActions'
import { typebotViewer } from 'utils/playwright/testHelpers'
import { getTestAsset } from '@/test/utils/playwright'
// const THREE_GIGABYTES = 3 * 1024 * 1024 * 1024
test('should work as expected', async ({ page, browser }) => {
const typebotId = cuid()
await importTypebotInDatabase(getTestAsset('typebots/fileUpload.json'), {
id: typebotId,
publicId: `${typebotId}-public`,
})
await page.goto(`/${typebotId}-public`)
await typebotViewer(page)
.locator(`input[type="file"]`)
.setInputFiles([
getTestAsset('typebots/api.json'),
getTestAsset('typebots/fileUpload.json'),
getTestAsset('typebots/hugeGroup.json'),
])
await expect(typebotViewer(page).locator(`text="3"`)).toBeVisible()
await typebotViewer(page).locator('text="Upload 3 files"').click()
await expect(
typebotViewer(page).locator(`text="3 files uploaded"`)
).toBeVisible()
await page.goto(`${process.env.NEXTAUTH_URL}/typebots/${typebotId}/results`)
await expect(page.getByRole('link', { name: 'api.json' })).toHaveAttribute(
'href',
/.+\/api\.json/
)
await expect(
page.getByRole('link', { name: 'fileUpload.json' })
).toHaveAttribute('href', /.+\/fileUpload\.json/)
await expect(
page.getByRole('link', { name: 'hugeGroup.json' })
).toHaveAttribute('href', /.+\/hugeGroup\.json/)
await page.click('[data-testid="checkbox"] >> nth=0')
const [download] = await Promise.all([
page.waitForEvent('download'),
page.locator('text="Export"').click(),
])
const downloadPath = await download.path()
expect(downloadPath).toBeDefined()
const file = readFileSync(downloadPath as string).toString()
const { data } = parse(file)
expect(data).toHaveLength(2)
expect((data[1] as unknown[])[1]).toContain(process.env.S3_ENDPOINT)
const urls = (
await Promise.all(
[
page.getByRole('link', { name: 'api.json' }),
page.getByRole('link', { name: 'fileUpload.json' }),
page.getByRole('link', { name: 'hugeGroup.json' }),
].map((elem) => elem.getAttribute('href'))
)
).filter(isDefined)
const page2 = await browser.newPage()
await page2.goto(urls[0])
await expect(page2.locator('pre')).toBeVisible()
await page.locator('button >> text="Delete"').click()
await page.locator('button >> text="Delete" >> nth=1').click()
await expect(page.locator('text="api.json"')).toBeHidden()
await page2.goto(urls[0])
await expect(page2.locator('pre')).toBeHidden()
})
// TODO: uncomment on 1st of November
// test.describe('Storage limit is reached', () => {
// const typebotId = cuid()
// test.beforeAll(async () => {
// await importTypebotInDatabase(
// path.join(__dirname, '../fixtures/typebots/fileUpload.json'),
// {
// id: typebotId,
// publicId: `${typebotId}-public`,
// }
// )
// await createResults({
// typebotId,
// count: 20,
// fakeStorage: THREE_GIGABYTES,
// })
// })
// test("shouldn't upload anything if limit has been reached", async ({
// page,
// }) => {
// await page.goto(`/${typebotId}-public`)
// await typebotViewer(page)
// .locator(`input[type="file"]`)
// .setInputFiles([
// path.join(__dirname, '../fixtures/typebots/api.json'),
// path.join(__dirname, '../fixtures/typebots/fileUpload.json'),
// path.join(__dirname, '../fixtures/typebots/hugeGroup.json'),
// ])
// await expect(typebotViewer(page).locator(`text="3"`)).toBeVisible()
// await typebotViewer(page).locator('text="Upload 3 files"').click()
// await expect(
// typebotViewer(page).locator(`text="3 files uploaded"`)
// ).toBeVisible()
// await page.evaluate(() =>
// window.localStorage.setItem('workspaceId', 'starterWorkspace')
// )
// await page.goto(`${process.env.NEXTAUTH_URL}/typebots/${typebotId}/results`)
// await expect(page.locator('text="150%"')).toBeVisible()
// await expect(page.locator('text="api.json"')).toBeHidden()
// })
// })

View File

@@ -0,0 +1 @@
export { validatePhoneNumber } from './utils/validatePhoneNumber'

View File

@@ -0,0 +1,4 @@
const phoneRegex = /^\+?[0-9]{6,}$/
export const validatePhoneNumber = (phoneNumber: string) =>
phoneRegex.test(phoneNumber)

View File

@@ -0,0 +1 @@
export { validateUrl } from './utils/validateUrl'

View File

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

View File

@@ -0,0 +1 @@
export * from './utils/executeChatwootBlock'

View File

@@ -0,0 +1,75 @@
import { ExecuteIntegrationResponse } from '@/features/chat'
import {
parseVariables,
parseCorrectValueType,
extractVariablesFromText,
} from '@/features/variables'
import { ChatwootBlock, ChatwootOptions, SessionState } from 'models'
const parseSetUserCode = (user: ChatwootOptions['user']) => `
window.$chatwoot.setUser("${user?.id ?? ''}", {
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,
}: ChatwootOptions) => `
if (window.$chatwoot) {
if(${Boolean(user)}) {
${parseSetUserCode(user)}
}
window.$chatwoot.toggle("open");
} 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 () {
if(${Boolean(user?.id || user?.email)}) {
${parseSetUserCode(user)}
}
window.$chatwoot.toggle("open");
});
};
})(document, "script");
}`
export const executeChatwootBlock = (
{ typebot: { variables } }: SessionState,
block: ChatwootBlock
): ExecuteIntegrationResponse => {
const chatwootCode = parseChatwootOpenCode(block.options)
return {
outgoingEdgeId: block.outgoingEdgeId,
integrations: {
chatwoot: {
codeToExecute: {
content: parseVariables(variables, { fieldToParse: 'id' })(
chatwootCode
),
args: extractVariablesFromText(variables)(chatwootCode).map(
(variable) => ({
id: variable.id,
value: parseCorrectValueType(variable.value),
})
),
},
},
},
}
}

View File

@@ -0,0 +1,31 @@
import test, { expect } from '@playwright/test'
import cuid from 'cuid'
import { createTypebots } from 'utils/playwright/databaseActions'
import { parseDefaultGroupWithBlock } from 'utils/playwright/databaseHelpers'
import { defaultChatwootOptions, IntegrationBlockType } from 'models'
import { typebotViewer } from 'utils/playwright/testHelpers'
const typebotId = cuid()
const chatwootTestWebsiteToken = 'tueXiiqEmrWUCZ4NUyoR7nhE'
test('should work as expected', async ({ page }) => {
await createTypebots([
{
id: typebotId,
...parseDefaultGroupWithBlock(
{
type: IntegrationBlockType.CHATWOOT,
options: {
...defaultChatwootOptions,
websiteToken: chatwootTestWebsiteToken,
},
},
{ withGoButton: true }
),
},
])
await page.goto(`/${typebotId}-public`)
await typebotViewer(page).getByRole('button', { name: 'Go' }).click()
await expect(page.locator('#chatwoot_live_chat_widget')).toBeVisible()
})

View File

@@ -0,0 +1 @@
export { executeGoogleAnalyticsBlock } from './utils/executeGoogleAnalyticsBlock'

View File

@@ -0,0 +1,13 @@
import { ExecuteIntegrationResponse } from '@/features/chat'
import { parseVariablesInObject } from '@/features/variables'
import { GoogleAnalyticsBlock, SessionState } from 'models'
export const executeGoogleAnalyticsBlock = (
{ typebot: { variables } }: SessionState,
block: GoogleAnalyticsBlock
): ExecuteIntegrationResponse => ({
outgoingEdgeId: block.outgoingEdgeId,
integrations: {
googleAnalytics: parseVariablesInObject(block.options, variables),
},
})

View File

@@ -0,0 +1 @@
export * from './utils'

View File

@@ -0,0 +1,30 @@
import { ExecuteIntegrationResponse } from '@/features/chat'
import { GoogleSheetsBlock, GoogleSheetsAction, SessionState } from 'models'
import { getRow } from './getRow'
import { insertRow } from './insertRow'
import { updateRow } from './updateRow'
export const executeGoogleSheetBlock = async (
state: SessionState,
block: GoogleSheetsBlock
): Promise<ExecuteIntegrationResponse> => {
if (!('action' in block.options))
return { outgoingEdgeId: block.outgoingEdgeId }
switch (block.options.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,89 @@
import { SessionState, GoogleSheetsGetOptions, VariableWithValue } from 'models'
import { saveErrorLog, saveSuccessLog } from '@/features/logs/api'
import { getAuthenticatedGoogleDoc } from './helpers'
import { parseVariables, updateVariables } from '@/features/variables'
import { isNotEmpty, byId } from 'utils'
import { ExecuteIntegrationResponse } from '@/features/chat'
export const getRow = async (
state: SessionState,
{
outgoingEdgeId,
options,
}: { outgoingEdgeId?: string; options: GoogleSheetsGetOptions }
): Promise<ExecuteIntegrationResponse> => {
const { sheetId, cellsToExtract, referenceCell } = options
if (!cellsToExtract || !sheetId || !referenceCell) return { outgoingEdgeId }
const variables = state.typebot.variables
const resultId = state.result.id
const doc = await getAuthenticatedGoogleDoc({
credentialsId: options.credentialsId,
spreadsheetId: options.spreadsheetId,
})
const parsedReferenceCell = {
column: referenceCell.column,
value: parseVariables(variables)(referenceCell.value),
}
const extractingColumns = cellsToExtract
.map((cell) => cell.column)
.filter(isNotEmpty)
try {
await doc.loadInfo()
const sheet = doc.sheetsById[sheetId]
const rows = await sheet.getRows()
const row = rows.find(
(row) =>
row[parsedReferenceCell.column as string] === parsedReferenceCell.value
)
if (!row) {
await saveErrorLog({
resultId,
message: "Couldn't find reference cell",
})
return { outgoingEdgeId }
}
const data: { [key: string]: string } = {
...extractingColumns.reduce(
(obj, column) => ({ ...obj, [column]: row[column] }),
{}
),
}
await saveSuccessLog({
resultId,
message: 'Succesfully fetched spreadsheet data',
})
const newVariables = options.cellsToExtract.reduce<VariableWithValue[]>(
(newVariables, cell) => {
const existingVariable = variables.find(byId(cell.variableId))
const value = data[cell.column ?? ''] ?? null
if (!existingVariable) return newVariables
return [
...newVariables,
{
...existingVariable,
value,
},
]
},
[]
)
const newSessionState = await updateVariables(state)(newVariables)
return {
outgoingEdgeId,
newSessionState,
}
} catch (err) {
await saveErrorLog({
resultId,
message: "Couldn't fetch spreadsheet data",
details: err,
})
}
return { outgoingEdgeId }
}

View File

@@ -0,0 +1,40 @@
import { parseVariables } from '@/features/variables'
import { getAuthenticatedGoogleClient } from '@/lib/google-sheets'
import { TRPCError } from '@trpc/server'
import { GoogleSpreadsheet } from 'google-spreadsheet'
import { Variable, Cell } from 'models'
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),
}
}, {})
export const getAuthenticatedGoogleDoc = async ({
credentialsId,
spreadsheetId,
}: {
credentialsId?: string
spreadsheetId?: string
}) => {
if (!credentialsId)
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Missing credentialsId or sheetId',
})
const doc = new GoogleSpreadsheet(spreadsheetId)
const auth = await getAuthenticatedGoogleClient(credentialsId)
if (!auth)
throw new TRPCError({
code: 'NOT_FOUND',
message: "Couldn't find credentials in database",
})
doc.useOAuth2Client(auth)
return doc
}

View File

@@ -0,0 +1 @@
export * from './executeGoogleSheetBlock'

View File

@@ -0,0 +1,38 @@
import { SessionState, GoogleSheetsInsertRowOptions } from 'models'
import { saveErrorLog, saveSuccessLog } from '@/features/logs/api'
import { getAuthenticatedGoogleDoc, parseCellValues } from './helpers'
import { ExecuteIntegrationResponse } from '@/features/chat'
export const insertRow = async (
{ result, typebot: { variables } }: SessionState,
{
outgoingEdgeId,
options,
}: { outgoingEdgeId?: string; options: GoogleSheetsInsertRowOptions }
): Promise<ExecuteIntegrationResponse> => {
if (!options.cellsToInsert || !options.sheetId) return { outgoingEdgeId }
const doc = await getAuthenticatedGoogleDoc({
credentialsId: options.credentialsId,
spreadsheetId: options.spreadsheetId,
})
const parsedValues = parseCellValues(variables)(options.cellsToInsert)
try {
await doc.loadInfo()
const sheet = doc.sheetsById[options.sheetId]
await sheet.addRow(parsedValues)
await saveSuccessLog({
resultId: result.id,
message: 'Succesfully inserted row',
})
} catch (err) {
await saveErrorLog({
resultId: result.id,
message: "Couldn't fetch spreadsheet data",
details: err,
})
}
return { outgoingEdgeId }
}

View File

@@ -0,0 +1,60 @@
import { SessionState, GoogleSheetsUpdateRowOptions } from 'models'
import { saveErrorLog, saveSuccessLog } from '@/features/logs/api'
import { getAuthenticatedGoogleDoc, parseCellValues } from './helpers'
import { TRPCError } from '@trpc/server'
import { parseVariables } from '@/features/variables'
import { ExecuteIntegrationResponse } from '@/features/chat'
export const updateRow = async (
{ result, typebot: { variables } }: SessionState,
{
outgoingEdgeId,
options,
}: { outgoingEdgeId?: string; options: GoogleSheetsUpdateRowOptions }
): Promise<ExecuteIntegrationResponse> => {
const { sheetId, referenceCell } = options
if (!options.cellsToUpsert || !sheetId || !referenceCell)
return { outgoingEdgeId }
const doc = await getAuthenticatedGoogleDoc({
credentialsId: options.credentialsId,
spreadsheetId: options.spreadsheetId,
})
const parsedReferenceCell = {
column: referenceCell.column,
value: parseVariables(variables)(referenceCell.value),
}
const parsedValues = parseCellValues(variables)(options.cellsToUpsert)
try {
await doc.loadInfo()
const sheet = doc.sheetsById[sheetId]
const rows = await sheet.getRows()
const updatingRowIndex = rows.findIndex(
(row) =>
row[parsedReferenceCell.column as string] === parsedReferenceCell.value
)
if (updatingRowIndex === -1) {
new TRPCError({
code: 'NOT_FOUND',
message: "Couldn't find row to update",
})
}
for (const key in parsedValues) {
rows[updatingRowIndex][key] = parsedValues[key]
}
await rows[updatingRowIndex].save()
await saveSuccessLog({
resultId: result.id,
message: 'Succesfully updated row',
})
} catch (err) {
await saveErrorLog({
resultId: result.id,
message: "Couldn't fetch spreadsheet data",
details: err,
})
}
return { outgoingEdgeId }
}

View File

@@ -0,0 +1,14 @@
export const defaultTransportOptions = {
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT),
secure: process.env.SMTP_SECURE ? process.env.SMTP_SECURE === 'true' : false,
auth: {
user: process.env.SMTP_USERNAME,
pass: process.env.SMTP_PASSWORD,
},
}
export const defaultFrom = {
name: process.env.SMTP_FROM?.split(' <')[0].replace(/"/g, ''),
email: process.env.SMTP_FROM?.match(/<(.*)>/)?.pop(),
}

View File

@@ -0,0 +1 @@
export { executeSendEmailBlock } from './utils/executeSendEmailBlock'

View File

@@ -0,0 +1,217 @@
import { getLinkedTypebots } from '@/features/blocks/logic/typebotLink/api'
import { ExecuteIntegrationResponse } from '@/features/chat'
import { saveErrorLog, saveSuccessLog } from '@/features/logs/api'
import { parseVariables } from '@/features/variables'
import prisma from '@/lib/prisma'
import { render } from '@faire/mjml-react/dist/src/utils/render'
import { DefaultBotNotificationEmail } from 'emails'
import {
PublicTypebot,
ResultValues,
SendEmailBlock,
SendEmailOptions,
SessionState,
SmtpCredentialsData,
} from 'models'
import { createTransport } from 'nodemailer'
import Mail from 'nodemailer/lib/mailer'
import { byId, isEmpty, isNotDefined, omit, parseAnswers } from 'utils'
import { decrypt } from 'utils/api'
import { defaultFrom, defaultTransportOptions } from '../constants'
export const executeSendEmailBlock = async (
{ result, typebot }: SessionState,
block: SendEmailBlock
): Promise<ExecuteIntegrationResponse> => {
const { options } = block
const { variables } = typebot
await sendEmail({
typebotId: typebot.id,
resultId: result.id,
credentialsId: options.credentialsId,
recipients: options.recipients.map(parseVariables(variables)),
subject: parseVariables(variables)(options.subject ?? ''),
body: parseVariables(variables)(options.body ?? ''),
cc: (options.cc ?? []).map(parseVariables(variables)),
bcc: (options.bcc ?? []).map(parseVariables(variables)),
replyTo: options.replyTo
? parseVariables(variables)(options.replyTo)
: undefined,
fileUrls:
variables.find(byId(options.attachmentsVariableId))?.value ?? undefined,
isCustomBody: options.isCustomBody,
isBodyCode: options.isBodyCode,
})
return { outgoingEdgeId: block.outgoingEdgeId }
}
const sendEmail = async ({
typebotId,
resultId,
credentialsId,
recipients,
body,
subject,
cc,
bcc,
replyTo,
isBodyCode,
isCustomBody,
fileUrls,
}: SendEmailOptions & {
typebotId: string
resultId: string
fileUrls?: string
}) => {
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,
typebotId,
resultId,
})
if (!emailBody) {
await saveErrorLog({
resultId,
message: 'Email not sent',
details: {
transportConfig,
recipients,
subject,
cc,
bcc,
replyTo,
emailBody,
},
})
}
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?.split(', ').map((url) => ({ path: url })),
...emailBody,
}
try {
const info = await transporter.sendMail(email)
await saveSuccessLog({
resultId,
message: 'Email successfully sent',
details: {
transportConfig: {
...transportConfig,
auth: { user: transportConfig.auth.user, pass: '******' },
},
email,
},
})
} catch (err) {
await saveErrorLog({
resultId,
message: 'Email not sent',
details: {
transportConfig: {
...transportConfig,
auth: { user: transportConfig.auth.user, pass: '******' },
},
email,
error: err,
},
})
}
}
const getEmailInfo = async (
credentialsId: string
): Promise<SmtpCredentialsData | 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 decrypt(credentials.data, credentials.iv) as SmtpCredentialsData
}
const getEmailBody = async ({
body,
isCustomBody,
isBodyCode,
typebotId,
resultId,
}: {
typebotId: string
resultId: string
} & 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 typebot = (await prisma.publicTypebot.findUnique({
where: { typebotId },
})) as unknown as PublicTypebot
if (!typebot) return
const linkedTypebots = await getLinkedTypebots(typebot)
const resultValues = (await prisma.result.findUnique({
where: { id: resultId },
include: { answers: true },
})) as ResultValues | null
if (!resultValues) return
const answers = parseAnswers(typebot, linkedTypebots)(resultValues)
return {
html: render(
<DefaultBotNotificationEmail
resultsUrl={`${process.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,
}
}

View File

@@ -0,0 +1,56 @@
import test, { expect } from '@playwright/test'
import { createSmtpCredentials } from '../../../../test/utils/databaseActions'
import cuid from 'cuid'
import { SmtpCredentialsData } from 'models'
import { importTypebotInDatabase } from 'utils/playwright/databaseActions'
import { typebotViewer } from 'utils/playwright/testHelpers'
import { getTestAsset } from '@/test/utils/playwright'
const mockSmtpCredentials: SmtpCredentialsData = {
from: {
email: 'marley.cummings@ethereal.email',
name: 'Marley Cummings',
},
host: 'smtp.ethereal.email',
port: 587,
username: 'marley.cummings@ethereal.email',
password: 'E5W1jHbAmv5cXXcut2',
}
test.beforeAll(async () => {
try {
const credentialsId = 'send-email-credentials'
await createSmtpCredentials(credentialsId, mockSmtpCredentials)
} catch (err) {
console.error(err)
}
})
test('should send an email', async ({ page }) => {
const typebotId = cuid()
await importTypebotInDatabase(getTestAsset('typebots/sendEmail.json'), {
id: typebotId,
publicId: `${typebotId}-public`,
})
await page.goto(`/${typebotId}-public`)
const [response] = await Promise.all([
page.waitForResponse((resp) =>
resp.request().url().includes(`integrations/email`)
),
typebotViewer(page).locator('text=Send email').click(),
])
const { previewUrl } = await response.json()
await page.goto(previewUrl)
await expect(page.locator('text="Hey!"')).toBeVisible()
await expect(
page.locator(`text="${mockSmtpCredentials.from.name}"`)
).toBeVisible()
await expect(page.locator('text="<test1@gmail.com>" >> nth=0')).toBeVisible()
await expect(page.locator('text="<test2@gmail.com>" >> nth=0')).toBeVisible()
await expect(
page.locator('text="<baptiste.arnaud95@gmail.com>" >> nth=0')
).toBeVisible()
await page.goto(`${process.env.NEXTAUTH_URL}/typebots/${typebotId}/results`)
await page.click('text="See logs"')
await expect(page.locator('text="Email successfully sent"')).toBeVisible()
})

View File

@@ -0,0 +1 @@
export * from './utils'

View File

@@ -0,0 +1,276 @@
import { ExecuteIntegrationResponse } from '@/features/chat'
import { saveErrorLog, saveSuccessLog } from '@/features/logs/api'
import { parseVariables, updateVariables } from '@/features/variables'
import prisma from '@/lib/prisma'
import {
WebhookBlock,
ZapierBlock,
MakeComBlock,
PabblyConnectBlock,
VariableWithUnknowValue,
SessionState,
Webhook,
Typebot,
Variable,
WebhookResponse,
WebhookOptions,
defaultWebhookAttributes,
HttpMethod,
ResultValues,
PublicTypebot,
KeyValue,
} from 'models'
import { stringify } from 'qs'
import { byId, omit, parseAnswers } from 'utils'
import got, { Method, Headers, HTTPError } from 'got'
import { getResultValues } from '@/features/results/api'
import { getLinkedTypebots } from '@/features/blocks/logic/typebotLink/api'
import { parseSampleResult } from './parseSampleResult'
export const executeWebhookBlock = async (
state: SessionState,
block: WebhookBlock | ZapierBlock | MakeComBlock | PabblyConnectBlock
): Promise<ExecuteIntegrationResponse> => {
const { typebot, result } = state
const webhook = (await prisma.webhook.findUnique({
where: { id: block.webhookId },
})) as Webhook | null
if (!webhook) {
await saveErrorLog({
resultId: result.id,
message: `Couldn't find webhook`,
})
return { outgoingEdgeId: block.outgoingEdgeId }
}
const preparedWebhook = prepareWebhookAttributes(webhook, block.options)
const resultValues = await getResultValues(result.id)
if (!resultValues) return { outgoingEdgeId: block.outgoingEdgeId }
const webhookResponse = await executeWebhook(typebot)(
preparedWebhook,
typebot.variables,
block.groupId,
resultValues,
result.id
)
const status = webhookResponse.statusCode.toString()
const isError = status.startsWith('4') || status.startsWith('5')
if (isError) {
await saveErrorLog({
resultId: result.id,
message: `Webhook returned error: ${webhookResponse.data}`,
details: JSON.stringify(webhookResponse.data, null, 2).substring(0, 1000),
})
} else {
await saveSuccessLog({
resultId: result.id,
message: `Webhook returned success: ${webhookResponse.data}`,
details: JSON.stringify(webhookResponse.data, null, 2).substring(0, 1000),
})
}
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(webhookResponse)
return [...newVariables, { ...existingVariable, value }]
} catch (err) {
return newVariables
}
}, [])
if (newVariables.length > 0) {
const newSessionState = await updateVariables(state)(newVariables)
return {
outgoingEdgeId: block.outgoingEdgeId,
newSessionState,
}
}
return { outgoingEdgeId: block.outgoingEdgeId }
}
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)
export const executeWebhook =
(typebot: SessionState['typebot']) =>
async (
webhook: Webhook,
variables: Variable[],
groupId: string,
resultValues: ResultValues,
resultId: string
): Promise<WebhookResponse> => {
if (!webhook.url || !webhook.method)
return {
statusCode: 400,
data: { message: `Webhook doesn't have url or method` },
}
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, variables) as
| Headers
| undefined
const queryParams = stringify(
convertKeyValueTableToObject(webhook.queryParams, variables)
)
const contentType = headers ? headers['Content-Type'] : undefined
const linkedTypebots = await getLinkedTypebots(typebot)
const bodyContent = await getBodyContent(
typebot,
linkedTypebots
)({
body: webhook.body,
resultValues,
groupId,
})
const { data: body, isJson } =
bodyContent && webhook.method !== HttpMethod.GET
? safeJsonParse(
parseVariables(variables, {
escapeForJson: !checkIfBodyIsAVariable(bodyContent),
})(bodyContent)
)
: { data: undefined, isJson: false }
const request = {
url: parseVariables(variables)(
webhook.url + (queryParams !== '' ? `?${queryParams}` : '')
),
method: webhook.method as Method,
headers,
...basicAuth,
json:
contentType !== 'x-www-form-urlencoded' && body && isJson
? body
: undefined,
form: contentType === 'x-www-form-urlencoded' && body ? body : undefined,
body: body && !isJson ? body : undefined,
}
try {
const response = await got(request.url, omit(request, 'url'))
await saveSuccessLog({
resultId,
message: 'Webhook successfuly executed.',
details: {
statusCode: response.statusCode,
request,
response: safeJsonParse(response.body).data,
},
})
return {
statusCode: response.statusCode,
data: safeJsonParse(response.body).data,
}
} catch (error) {
if (error instanceof HTTPError) {
const response = {
statusCode: error.response.statusCode,
data: safeJsonParse(error.response.body as string).data,
}
await saveErrorLog({
resultId,
message: 'Webhook returned an error',
details: {
request,
response,
},
})
return response
}
const response = {
statusCode: 500,
data: { message: `Error from Typebot server: ${error}` },
}
console.error(error)
await saveErrorLog({
resultId,
message: 'Webhook failed to execute',
details: {
request,
response,
},
})
return response
}
}
const getBodyContent =
(
typebot: Pick<Typebot | PublicTypebot, 'groups' | 'variables' | 'edges'>,
linkedTypebots: (Typebot | PublicTypebot)[]
) =>
async ({
body,
resultValues,
groupId,
}: {
body?: string | null
resultValues?: ResultValues
groupId: string
}): Promise<string | undefined> => {
if (!body) return
return body === '{{state}}'
? JSON.stringify(
resultValues
? parseAnswers(typebot, linkedTypebots)(resultValues)
: await parseSampleResult(typebot, linkedTypebots)(groupId)
)
: 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 ?? ''),
}
}, {})
}
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,2 @@
export * from './executeWebhookBlock'
export * from './parseSampleResult'

View File

@@ -0,0 +1,192 @@
import {
InputBlock,
InputBlockType,
LogicBlockType,
PublicTypebot,
ResultHeaderCell,
Block,
Typebot,
TypebotLinkBlock,
} from 'models'
import { isInputBlock, byId, parseResultHeader, isNotDefined } from 'utils'
export const parseSampleResult =
(
typebot: Pick<Typebot | PublicTypebot, 'groups' | 'variables' | 'edges'>,
linkedTypebots: (Typebot | PublicTypebot)[]
) =>
async (
currentGroupId: string
): 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 ⬇️',
'Submitted at': new Date().toISOString(),
...parseResultSample(linkedInputBlocks, header),
}
}
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[]
) =>
headerCells.reduce<Record<string, string | boolean | undefined>>(
(resultSample, cell) => {
const inputBlock = inputBlocks.find((inputBlock) =>
cell.blocks?.some((block) => block.id === inputBlock.id)
)
if (isNotDefined(inputBlock)) {
if (cell.variableIds)
return {
...resultSample,
[cell.label]: 'content',
}
return resultSample
}
const value = getSampleValue(inputBlock)
return {
...resultSample,
[cell.label]: value,
}
},
{}
)
const getSampleValue = (block: InputBlock) => {
switch (block.type) {
case InputBlockType.CHOICE:
return block.options.isMultipleChoice
? block.items.map((i) => i.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'
}
}
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,198 @@
import test, { expect } from '@playwright/test'
import cuid from 'cuid'
import { HttpMethod, Typebot } from 'models'
import {
createWebhook,
deleteTypebots,
deleteWebhooks,
importTypebotInDatabase,
} from 'utils/playwright/databaseActions'
import { typebotViewer } from 'utils/playwright/testHelpers'
import { apiToken } from 'utils/playwright/databaseSetup'
import { getTestAsset } from '@/test/utils/playwright'
test.describe('Bot', () => {
const typebotId = cuid()
test.beforeEach(async () => {
await importTypebotInDatabase(getTestAsset('typebots/webhook.json'), {
id: typebotId,
publicId: `${typebotId}-public`,
})
await createWebhook(typebotId, {
id: 'failing-webhook',
url: 'http://localhost:3001/api/mock/fail',
method: HttpMethod.POST,
})
await createWebhook(typebotId, {
id: 'partial-body-webhook',
url: 'http://localhost:3000/api/mock/webhook-easy-config',
method: HttpMethod.POST,
body: `{
"name": "{{Name}}",
"age": {{Age}},
"gender": "{{Gender}}"
}`,
})
await createWebhook(typebotId, {
id: 'full-body-webhook',
url: 'http://localhost:3000/api/mock/webhook-easy-config',
method: HttpMethod.POST,
body: `{{Full body}}`,
})
})
test.afterEach(async () => {
await deleteTypebots([typebotId])
await deleteWebhooks([
'failing-webhook',
'partial-body-webhook',
'full-body-webhook',
])
})
test('should execute webhooks properly', async ({ page }) => {
await page.goto(`/${typebotId}-public`)
await typebotViewer(page).locator('text=Send failing webhook').click()
await typebotViewer(page)
.locator('[placeholder="Type a name..."]')
.fill('John')
await typebotViewer(page).locator('text="Send"').click()
await typebotViewer(page)
.locator('[placeholder="Type an age..."]')
.fill('30')
await typebotViewer(page).locator('text="Send"').click()
await typebotViewer(page).locator('text="Male"').click()
await expect(
typebotViewer(page).getByText('{"name":"John","age":25,"gender":"male"}')
).toBeVisible()
await expect(
typebotViewer(page).getByText('{"name":"John","age":30,"gender":"Male"}')
).toBeVisible()
await page.goto(`http://localhost:3000/typebots/${typebotId}/results`)
await page.click('text="See logs"')
await expect(
page.locator('text="Webhook successfuly executed." >> nth=1')
).toBeVisible()
await expect(page.locator('text="Webhook returned an error"')).toBeVisible()
})
})
test.describe('API', () => {
const typebotId = 'webhook-flow'
test.beforeAll(async () => {
try {
await importTypebotInDatabase(getTestAsset('typebots/api.json'), {
id: typebotId,
})
await createWebhook(typebotId)
} catch (err) {
console.log(err)
}
})
test('can list typebots', async ({ request }) => {
expect((await request.get(`/api/typebots`)).status()).toBe(401)
const response = await request.get(`/api/typebots`, {
headers: { Authorization: `Bearer ${apiToken}` },
})
const { typebots } = (await response.json()) as { typebots: Typebot[] }
expect(typebots.length).toBeGreaterThanOrEqual(1)
expect(typebots.find((typebot) => typebot.id === typebotId)).toMatchObject({
id: typebotId,
publishedTypebotId: null,
name: 'My typebot',
})
})
test('can get webhook blocks', async ({ request }) => {
expect(
(await request.get(`/api/typebots/${typebotId}/webhookBlocks`)).status()
).toBe(401)
const response = await request.get(
`/api/typebots/${typebotId}/webhookBlocks`,
{
headers: { Authorization: `Bearer ${apiToken}` },
}
)
const { blocks } = await response.json()
expect(blocks).toHaveLength(1)
expect(blocks[0]).toEqual({
blockId: 'webhookBlock',
name: 'Webhook > webhookBlock',
})
})
test('can subscribe webhook', async ({ request }) => {
expect(
(
await request.post(
`/api/typebots/${typebotId}/blocks/webhookBlock/subscribeWebhook`,
{ data: { url: 'https://test.com' } }
)
).status()
).toBe(401)
const response = await request.post(
`/api/typebots/${typebotId}/blocks/webhookBlock/subscribeWebhook`,
{
headers: {
Authorization: `Bearer ${apiToken}`,
},
data: { url: 'https://test.com' },
}
)
const body = await response.json()
expect(body).toEqual({
message: 'success',
})
})
test('can unsubscribe webhook', async ({ request }) => {
expect(
(
await request.post(
`/api/typebots/${typebotId}/blocks/webhookBlock/unsubscribeWebhook`
)
).status()
).toBe(401)
const response = await request.post(
`/api/typebots/${typebotId}/blocks/webhookBlock/unsubscribeWebhook`,
{
headers: { Authorization: `Bearer ${apiToken}` },
}
)
const body = await response.json()
expect(body).toEqual({
message: 'success',
})
})
test('can get a sample result', async ({ request }) => {
expect(
(
await request.get(
`/api/typebots/${typebotId}/blocks/webhookBlock/sampleResult`
)
).status()
).toBe(401)
const response = await request.get(
`/api/typebots/${typebotId}/blocks/webhookBlock/sampleResult`,
{
headers: { Authorization: `Bearer ${apiToken}` },
}
)
const data = await response.json()
expect(data).toMatchObject({
message: 'This is a sample result, it has been generated ⬇️',
Welcome: 'Hi!',
Email: 'test@email.com',
Name: 'answer value',
Services: 'Website dev, Content Marketing, Social Media, UI / UX Design',
'Additional information': 'answer value',
})
})
})

View File

@@ -0,0 +1 @@
export { executeCode } from './utils/executeCode'

View File

@@ -0,0 +1,34 @@
import { ExecuteLogicResponse } from '@/features/chat'
import {
parseVariables,
parseCorrectValueType,
extractVariablesFromText,
} from '@/features/variables'
import { CodeBlock, SessionState } from 'models'
export const executeCode = (
{ typebot: { variables } }: SessionState,
block: CodeBlock
): ExecuteLogicResponse => {
if (!block.options.content) return { outgoingEdgeId: block.outgoingEdgeId }
const content = parseVariables(variables, { fieldToParse: 'id' })(
block.options.content
)
const args = extractVariablesFromText(variables)(block.options.content).map(
(variable) => ({
id: variable.id,
value: parseCorrectValueType(variable.value),
})
)
return {
outgoingEdgeId: block.outgoingEdgeId,
logic: {
codeToExecute: {
content,
args,
},
},
}
}

View File

@@ -0,0 +1 @@
export { executeCondition } from './utils/executeCondition'

View File

@@ -0,0 +1,60 @@
import { ExecuteLogicResponse } from '@/features/chat'
import { parseVariables } from '@/features/variables'
import {
Comparison,
ComparisonOperators,
ConditionBlock,
LogicalOperator,
SessionState,
Variable,
} from 'models'
import { isNotDefined, isDefined } from 'utils'
export const executeCondition = (
{ typebot: { variables } }: SessionState,
block: ConditionBlock
): ExecuteLogicResponse => {
const passedCondition = block.items.find((item) => {
const { content } = item
const isConditionPassed =
content.logicalOperator === LogicalOperator.AND
? content.comparisons.every(executeComparison(variables))
: content.comparisons.some(executeComparison(variables))
return isConditionPassed
})
return {
outgoingEdgeId: passedCondition
? passedCondition.outgoingEdgeId
: block.outgoingEdgeId,
}
}
const executeComparison =
(variables: Variable[]) => (comparison: Comparison) => {
if (!comparison?.variableId) return false
const inputValue = (
variables.find((v) => v.id === comparison.variableId)?.value ?? ''
).trim()
const value = parseVariables(variables)(comparison.value).trim()
if (isNotDefined(value)) return false
switch (comparison.comparisonOperator) {
case ComparisonOperators.CONTAINS: {
return inputValue.toLowerCase().includes(value.toLowerCase())
}
case ComparisonOperators.EQUAL: {
return inputValue === value
}
case ComparisonOperators.NOT_EQUAL: {
return inputValue !== value
}
case ComparisonOperators.GREATER: {
return parseFloat(inputValue) > parseFloat(value)
}
case ComparisonOperators.LESS: {
return parseFloat(inputValue) < parseFloat(value)
}
case ComparisonOperators.IS_SET: {
return isDefined(inputValue) && inputValue.length > 0
}
}
}

View File

@@ -0,0 +1 @@
export { executeRedirect } from './utils/executeRedirect'

View File

@@ -0,0 +1,16 @@
import { ExecuteLogicResponse } from '@/features/chat'
import { parseVariables } from '@/features/variables'
import { RedirectBlock, SessionState } from 'models'
import { sanitizeUrl } from 'utils'
export const executeRedirect = (
{ typebot: { variables } }: SessionState,
block: RedirectBlock
): ExecuteLogicResponse => {
if (!block.options?.url) return { outgoingEdgeId: block.outgoingEdgeId }
const formattedUrl = sanitizeUrl(parseVariables(variables)(block.options.url))
return {
logic: { redirectUrl: formattedUrl },
outgoingEdgeId: block.outgoingEdgeId,
}
}

View File

@@ -0,0 +1 @@
export { executeSetVariable } from './utils/executeSetVariable'

View File

@@ -0,0 +1,50 @@
import { SessionState, SetVariableBlock, Variable } from 'models'
import { byId } from 'utils'
import {
parseVariables,
parseCorrectValueType,
updateVariables,
} from '@/features/variables'
import { ExecuteLogicResponse } from '@/features/chat'
export const executeSetVariable = async (
state: SessionState,
block: SetVariableBlock
): Promise<ExecuteLogicResponse> => {
const { variables } = state.typebot
if (!block.options?.variableId)
return {
outgoingEdgeId: block.outgoingEdgeId,
}
const evaluatedExpression = block.options.expressionToEvaluate
? evaluateSetVariableExpression(variables)(
block.options.expressionToEvaluate
)
: undefined
const existingVariable = variables.find(byId(block.options.variableId))
if (!existingVariable) return { outgoingEdgeId: block.outgoingEdgeId }
const newVariable = {
...existingVariable,
value: evaluatedExpression,
}
const newSessionState = await updateVariables(state)([newVariable])
return {
outgoingEdgeId: block.outgoingEdgeId,
newSessionState,
}
}
const evaluateSetVariableExpression =
(variables: Variable[]) =>
(str: string): unknown => {
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) => parseCorrectValueType(v.value)))
} catch (err) {
console.log(`Evaluating: ${evaluating}`, err)
return str
}
}

View File

@@ -0,0 +1 @@
export * from './utils'

View File

@@ -0,0 +1,138 @@
import { ExecuteLogicResponse } from '@/features/chat'
import { saveErrorLog } from '@/features/logs/api'
import prisma from '@/lib/prisma'
import { TypebotLinkBlock, Edge, SessionState, TypebotInSession } from 'models'
import { byId } from 'utils'
export const executeTypebotLink = async (
state: SessionState,
block: TypebotLinkBlock
): Promise<ExecuteLogicResponse> => {
if (!block.options.typebotId) {
saveErrorLog({
resultId: state.result.id,
message: 'Failed to link typebot',
details: 'Typebot ID is not specified',
})
return { outgoingEdgeId: block.outgoingEdgeId }
}
const linkedTypebot = await getLinkedTypebot(state, block.options.typebotId)
if (!linkedTypebot) {
saveErrorLog({
resultId: state.result.id,
message: 'Failed to link typebot',
details: `Typebot with ID ${block.options.typebotId} not found`,
})
return { outgoingEdgeId: block.outgoingEdgeId }
}
let newSessionState = addLinkedTypebotToState(state, block, linkedTypebot)
const nextGroupId =
block.options.groupId ??
linkedTypebot.groups.find((b) => b.blocks.some((s) => s.type === 'start'))
?.id
if (!nextGroupId) {
saveErrorLog({
resultId: state.result.id,
message: 'Failed to link typebot',
details: `Group with ID "${block.options.groupId}" not found`,
})
return { outgoingEdgeId: block.outgoingEdgeId }
}
const portalEdge: Edge = {
id: (Math.random() * 1000).toString(),
from: { blockId: '', groupId: '' },
to: {
groupId: nextGroupId,
},
}
newSessionState = addEdgeToTypebot(newSessionState, portalEdge)
return {
outgoingEdgeId: portalEdge.id,
newSessionState,
}
}
const addEdgeToTypebot = (state: SessionState, edge: Edge): SessionState => ({
...state,
typebot: {
...state.typebot,
edges: [...state.typebot.edges, edge],
},
})
const addLinkedTypebotToState = (
state: SessionState,
block: TypebotLinkBlock,
linkedTypebot: TypebotInSession
): SessionState => ({
...state,
typebot: {
...state.typebot,
groups: [...state.typebot.groups, ...linkedTypebot.groups],
variables: [...state.typebot.variables, ...linkedTypebot.variables],
edges: [...state.typebot.edges, ...linkedTypebot.edges],
},
linkedTypebots: {
typebots: [
...state.linkedTypebots.typebots.filter(
(existingTypebots) => existingTypebots.id !== linkedTypebot.id
),
],
queue: block.outgoingEdgeId
? [
...state.linkedTypebots.queue,
{ edgeId: block.outgoingEdgeId, typebotId: state.currentTypebotId },
]
: state.linkedTypebots.queue,
},
currentTypebotId: linkedTypebot.id,
})
const getLinkedTypebot = async (
state: SessionState,
typebotId: string
): Promise<TypebotInSession | null> => {
const { typebot, isPreview } = state
if (typebotId === 'current') return typebot
const availableTypebots =
'linkedTypebots' in state
? [typebot, ...state.linkedTypebots.typebots]
: [typebot]
const linkedTypebot =
availableTypebots.find(byId(typebotId)) ??
(await fetchTypebot({ isPreview }, typebotId))
return linkedTypebot
}
const fetchTypebot = async (
{ isPreview }: Pick<SessionState, 'isPreview'>,
typebotId: string
): Promise<TypebotInSession | null> => {
if (isPreview) {
const typebot = await prisma.typebot.findUnique({
where: { id: typebotId },
select: {
id: true,
edges: true,
groups: true,
variables: true,
},
})
return typebot as TypebotInSession
}
const typebot = await prisma.publicTypebot.findUnique({
where: { typebotId },
select: {
id: true,
edges: true,
groups: true,
variables: true,
},
})
if (!typebot) return null
return {
...typebot,
id: typebotId,
} as TypebotInSession
}

View File

@@ -0,0 +1,41 @@
import prisma from '@/lib/prisma'
import { canReadTypebots } from '@/utils/api/dbRules'
import { User } from 'db'
import {
LogicBlockType,
PublicTypebot,
Typebot,
TypebotLinkBlock,
} from 'models'
import { isDefined } from 'utils'
export const getLinkedTypebots = async (
typebot: Pick<PublicTypebot, 'groups'>,
user?: User
): Promise<(Typebot | PublicTypebot)[]> => {
const linkedTypebotIds = (
typebot.groups
.flatMap((g) => g.blocks)
.filter(
(s) =>
s.type === LogicBlockType.TYPEBOT_LINK &&
isDefined(s.options.typebotId)
) as TypebotLinkBlock[]
).map((s) => s.options.typebotId as string)
if (linkedTypebotIds.length === 0) return []
const typebots = (await ('typebotId' in typebot
? prisma.publicTypebot.findMany({
where: { id: { in: linkedTypebotIds } },
})
: prisma.typebot.findMany({
where: user
? {
AND: [
{ id: { in: linkedTypebotIds } },
canReadTypebots(linkedTypebotIds, user as User),
],
}
: { id: { in: linkedTypebotIds } },
}))) as unknown as (Typebot | PublicTypebot)[]
return typebots
}

View File

@@ -0,0 +1,2 @@
export * from './executeTypebotLink'
export * from './getLinkedTypebots'

View File

@@ -0,0 +1,38 @@
import { getTestAsset } from '@/test/utils/playwright'
import test, { expect } from '@playwright/test'
import { importTypebotInDatabase } from 'utils/playwright/databaseActions'
import { typebotViewer } from 'utils/playwright/testHelpers'
const typebotId = 'cl0ibhi7s0018n21aarlmg0cm'
const linkedTypebotId = 'cl0ibhv8d0130n21aw8doxhj5'
test.beforeAll(async () => {
try {
await importTypebotInDatabase(
getTestAsset('typebots/linkTypebots/1.json'),
{ id: typebotId, publicId: `${typebotId}-public` }
)
await importTypebotInDatabase(
getTestAsset('typebots/linkTypebots/2.json'),
{ id: linkedTypebotId, publicId: `${linkedTypebotId}-public` }
)
} catch (err) {
console.error(err)
}
})
test('should work as expected', async ({ page }) => {
await page.goto(`/${typebotId}-public`)
await typebotViewer(page).locator('input').fill('Hello there!')
await Promise.all([
page.waitForResponse(
(resp) =>
resp.request().url().includes(`/api/typebots/t/results`) &&
resp.status() === 200 &&
resp.request().method() === 'PUT'
),
typebotViewer(page).locator('input').press('Enter'),
])
await page.goto(`${process.env.NEXTAUTH_URL}/typebots/${typebotId}/results`)
await expect(page.locator('text=Hello there!')).toBeVisible()
})