⚗️ Implement chat API
This commit is contained in:
@ -0,0 +1 @@
|
||||
export * from './utils/executeChatwootBlock'
|
@ -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),
|
||||
})
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
@ -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()
|
||||
})
|
@ -0,0 +1 @@
|
||||
export { executeGoogleAnalyticsBlock } from './utils/executeGoogleAnalyticsBlock'
|
@ -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),
|
||||
},
|
||||
})
|
@ -0,0 +1 @@
|
||||
export * from './utils'
|
@ -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,
|
||||
})
|
||||
}
|
||||
}
|
@ -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 }
|
||||
}
|
@ -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
|
||||
}
|
@ -0,0 +1 @@
|
||||
export * from './executeGoogleSheetBlock'
|
@ -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 }
|
||||
}
|
@ -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 }
|
||||
}
|
@ -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(),
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { executeSendEmailBlock } from './utils/executeSendEmailBlock'
|
@ -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,
|
||||
}
|
||||
}
|
@ -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()
|
||||
})
|
@ -0,0 +1 @@
|
||||
export * from './utils'
|
@ -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 }
|
||||
}
|
||||
}
|
@ -0,0 +1,2 @@
|
||||
export * from './executeWebhookBlock'
|
||||
export * from './parseSampleResult'
|
@ -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
|
||||
}
|
@ -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',
|
||||
})
|
||||
})
|
||||
})
|
Reference in New Issue
Block a user