2
0

⚗️ 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 @@
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 @@
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

@ -1,5 +1,5 @@
import test, { expect } from '@playwright/test'
import { createSmtpCredentials } from '../../test/utils/databaseActions'
import { createSmtpCredentials } from '../../../../test/utils/databaseActions'
import cuid from 'cuid'
import { SmtpCredentialsData } from 'models'
import { importTypebotInDatabase } from 'utils/playwright/databaseActions'

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 @@
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

@ -10,7 +10,7 @@ import {
import { isDefined } from 'utils'
export const getLinkedTypebots = async (
typebot: Typebot | PublicTypebot,
typebot: Pick<PublicTypebot, 'groups'>,
user?: User
): Promise<(Typebot | PublicTypebot)[]> => {
const linkedTypebotIds = (

View File

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

View File

@ -0,0 +1,6 @@
import { router } from '@/utils/server/trpc'
import { sendMessageProcedure } from './procedures'
export const chatRouter = router({
sendMessage: sendMessageProcedure,
})

View File

@ -0,0 +1,2 @@
export * from './chatRouter'
export { getSession } from './utils'

View File

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

View File

@ -0,0 +1,177 @@
import prisma from '@/lib/prisma'
import { publicProcedure } from '@/utils/server/trpc'
import { TRPCError } from '@trpc/server'
import {
chatReplySchema,
ChatSession,
PublicTypebotWithName,
Result,
SessionState,
typebotSchema,
} from 'models'
import { z } from 'zod'
import { continueBotFlow, getSession, startBotFlow } from '../utils'
export const sendMessageProcedure = publicProcedure
.meta({
openapi: {
method: 'POST',
path: '/typebots/{typebotId}/sendMessage',
summary: 'Send a message',
description:
"To initiate a chat, don't provide a `sessionId` and enter any `message`.\n\nContinue the conversation by providing the `sessionId` and the `message` that should answer the previous question.\n\nSet the `isPreview` option to `true` to chat with the non-published version of the typebot.",
},
})
.input(
z.object({
typebotId: z.string({
description:
'[How can I find my typebot ID?](https://docs.typebot.io/api#how-to-find-my-typebotid)',
}),
message: z.string().describe('The answer to the previous question'),
sessionId: z
.string()
.optional()
.describe(
'Session ID that you get from the initial chat request to a bot'
),
isPreview: z.boolean().optional(),
})
)
.output(
chatReplySchema.and(
z.object({
sessionId: z.string().nullish(),
typebot: typebotSchema.pick({ theme: true, settings: true }).nullish(),
})
)
)
.query(async ({ input: { typebotId, sessionId, message } }) => {
const session = sessionId ? await getSession(sessionId) : null
if (!session) {
const { sessionId, typebot, messages, input } = await startSession(
typebotId
)
return {
sessionId,
typebot: typebot
? {
theme: typebot.theme,
settings: typebot.settings,
}
: null,
messages,
input,
}
} else {
const { messages, input, logic, newSessionState } = await continueBotFlow(
session.state
)(message)
await prisma.chatSession.updateMany({
where: { id: session.id },
data: {
state: newSessionState,
},
})
return {
messages,
input,
logic,
}
}
})
const startSession = async (typebotId: string) => {
const typebot = await prisma.typebot.findUnique({
where: { id: typebotId },
select: {
publishedTypebot: true,
name: true,
isClosed: true,
isArchived: true,
id: true,
},
})
if (!typebot?.publishedTypebot || typebot.isArchived)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Typebot not found',
})
if (typebot.isClosed)
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Typebot is closed',
})
const result = (await prisma.result.create({
data: { isCompleted: false, typebotId },
select: {
id: true,
variables: true,
hasStarted: true,
},
})) as Pick<Result, 'id' | 'variables' | 'hasStarted'>
const publicTypebot = typebot.publishedTypebot as PublicTypebotWithName
const initialState: SessionState = {
typebot: {
id: publicTypebot.typebotId,
groups: publicTypebot.groups,
edges: publicTypebot.edges,
variables: publicTypebot.variables,
},
linkedTypebots: {
typebots: [],
queue: [],
},
result: { id: result.id, variables: [], hasStarted: false },
isPreview: false,
currentTypebotId: publicTypebot.typebotId,
}
const {
messages,
input,
logic,
newSessionState: newInitialState,
} = await startBotFlow(initialState)
if (!input)
return {
messages,
typebot: null,
sessionId: null,
logic,
}
const sessionState: ChatSession['state'] = {
...(newInitialState ?? initialState),
currentBlock: {
groupId: input.groupId,
blockId: input.id,
},
}
const session = (await prisma.chatSession.create({
data: {
state: sessionState,
},
})) as ChatSession
return {
sessionId: session.id,
typebot: {
theme: publicTypebot.theme,
settings: publicTypebot.settings,
},
messages,
input,
logic,
}
}

View File

@ -0,0 +1,150 @@
import { validateButtonInput } from '@/features/blocks/inputs/buttons/api'
import { validateEmail } from '@/features/blocks/inputs/email/api'
import { validatePhoneNumber } from '@/features/blocks/inputs/phone/api'
import { validateUrl } from '@/features/blocks/inputs/url/api'
import prisma from '@/lib/prisma'
import { TRPCError } from '@trpc/server'
import {
Block,
BubbleBlockType,
ChatReply,
InputBlock,
InputBlockType,
SessionState,
Variable,
} from 'models'
import { isInputBlock } from 'utils'
import { executeGroup } from './executeGroup'
import { getNextGroup } from './getNextGroup'
export const continueBotFlow =
(state: SessionState) =>
async (
reply: string
): Promise<ChatReply & { newSessionState?: SessionState }> => {
const group = state.typebot.groups.find(
(group) => group.id === state.currentBlock?.groupId
)
const blockIndex =
group?.blocks.findIndex(
(block) => block.id === state.currentBlock?.blockId
) ?? -1
const block = blockIndex > 0 ? group?.blocks[blockIndex ?? 0] : null
if (!block || !group)
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Current block not found',
})
if (!isInputBlock(block))
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Current block is not an input block',
})
if (!isInputValid(reply, block)) return parseRetryMessage(block)
const newVariables = await processAndSaveAnswer(state, block)(reply)
const newSessionState = {
...state,
typebot: {
...state.typebot,
variables: newVariables,
},
}
const groupHasMoreBlocks = blockIndex < group.blocks.length - 1
if (groupHasMoreBlocks) {
return executeGroup(newSessionState)({
...group,
blocks: group.blocks.slice(blockIndex + 1),
})
}
const nextEdgeId = block.outgoingEdgeId
if (!nextEdgeId && state.linkedTypebots.queue.length === 0)
return { messages: [] }
const nextGroup = getNextGroup(newSessionState)(nextEdgeId)
if (!nextGroup) return { messages: [] }
return executeGroup(newSessionState)(nextGroup.group)
}
const processAndSaveAnswer =
(state: Pick<SessionState, 'result' | 'typebot'>, block: InputBlock) =>
async (reply: string): Promise<Variable[]> => {
await saveAnswer(state.result.id, block)(reply)
const newVariables = saveVariableValueIfAny(state, block)(reply)
return newVariables
}
const saveVariableValueIfAny =
(state: Pick<SessionState, 'result' | 'typebot'>, block: InputBlock) =>
(reply: string): Variable[] => {
if (!block.options.variableId) return state.typebot.variables
const variable = state.typebot.variables.find(
(variable) => variable.id === block.options.variableId
)
if (!variable) return state.typebot.variables
return [
...state.typebot.variables.filter(
(variable) => variable.id !== block.options.variableId
),
{
...variable,
value: reply,
},
]
}
const parseRetryMessage = (block: InputBlock) => ({
messages: [
{
type: BubbleBlockType.TEXT,
content: {
plainText:
'retryMessageContent' in block.options
? block.options.retryMessageContent
: 'Invalid message. Please, try again.',
richText: [],
html: '',
},
},
],
input: block,
})
const saveAnswer =
(resultId: string, block: InputBlock) => async (reply: string) => {
await prisma.answer.create({
data: {
resultId: resultId,
blockId: block.id,
groupId: block.groupId,
content: reply,
variableId: block.options.variableId,
},
})
}
export const isInputValid = (inputValue: string, block: Block): boolean => {
switch (block.type) {
case InputBlockType.EMAIL:
return validateEmail(inputValue)
case InputBlockType.PHONE:
return validatePhoneNumber(inputValue)
case InputBlockType.URL:
return validateUrl(inputValue)
case InputBlockType.CHOICE:
return validateButtonInput(block, inputValue)
}
return true
}

View File

@ -0,0 +1,113 @@
import { parseVariables } from '@/features/variables'
import {
BubbleBlock,
BubbleBlockType,
ChatMessageContent,
ChatReply,
Group,
SessionState,
} from 'models'
import {
isBubbleBlock,
isInputBlock,
isIntegrationBlock,
isLogicBlock,
} from 'utils'
import { executeLogic } from './executeLogic'
import { getNextGroup } from './getNextGroup'
import { executeIntegration } from './executeIntegration'
export const executeGroup =
(state: SessionState, currentReply?: ChatReply) =>
async (
group: Group
): Promise<ChatReply & { newSessionState?: SessionState }> => {
const messages: ChatReply['messages'] = currentReply?.messages ?? []
let logic: ChatReply['logic'] = currentReply?.logic
let integrations: ChatReply['integrations'] = currentReply?.integrations
let nextEdgeId = null
let newSessionState = state
for (const block of group.blocks) {
nextEdgeId = block.outgoingEdgeId
if (isBubbleBlock(block)) {
messages.push({
type: block.type,
content: parseBubbleBlockContent(newSessionState)(block),
})
continue
}
if (isInputBlock(block))
return {
messages,
input: block,
newSessionState: {
...newSessionState,
currentBlock: {
groupId: group.id,
blockId: block.id,
},
},
}
const executionResponse = isLogicBlock(block)
? await executeLogic(state)(block)
: isIntegrationBlock(block)
? await executeIntegration(state)(block)
: null
if (!executionResponse) continue
if ('logic' in executionResponse && executionResponse.logic)
logic = executionResponse.logic
if ('integrations' in executionResponse && executionResponse.integrations)
integrations = executionResponse.integrations
if (executionResponse.newSessionState)
newSessionState = executionResponse.newSessionState
if (executionResponse.outgoingEdgeId)
nextEdgeId = executionResponse.outgoingEdgeId
}
if (!nextEdgeId) return { messages, newSessionState, logic, integrations }
const nextGroup = getNextGroup(newSessionState)(nextEdgeId)
if (nextGroup?.updatedContext) newSessionState = nextGroup.updatedContext
if (!nextGroup) {
return { messages, newSessionState, logic, integrations }
}
return executeGroup(newSessionState, { messages, logic, integrations })(
nextGroup.group
)
}
const parseBubbleBlockContent =
({ typebot: { variables } }: SessionState) =>
(block: BubbleBlock): ChatMessageContent => {
switch (block.type) {
case BubbleBlockType.TEXT: {
const plainText = parseVariables(variables)(block.content.plainText)
const html = parseVariables(variables)(block.content.html)
return { plainText, html }
}
case BubbleBlockType.IMAGE: {
const url = parseVariables(variables)(block.content.url)
return { url }
}
case BubbleBlockType.VIDEO: {
const url = parseVariables(variables)(block.content.url)
return { url }
}
case BubbleBlockType.AUDIO: {
const url = parseVariables(variables)(block.content.url)
return { url }
}
case BubbleBlockType.EMBED: {
const url = parseVariables(variables)(block.content.url)
return { url }
}
}
}

View File

@ -0,0 +1,27 @@
import { executeChatwootBlock } from '@/features/blocks/integrations/chatwoot/api'
import { executeGoogleAnalyticsBlock } from '@/features/blocks/integrations/googleAnalytics/api'
import { executeGoogleSheetBlock } from '@/features/blocks/integrations/googleSheets/api'
import { executeSendEmailBlock } from '@/features/blocks/integrations/sendEmail/api'
import { executeWebhookBlock } from '@/features/blocks/integrations/webhook/api'
import { IntegrationBlock, IntegrationBlockType, SessionState } from 'models'
import { ExecuteIntegrationResponse } from '../../types'
export const executeIntegration =
(state: SessionState) =>
async (block: IntegrationBlock): Promise<ExecuteIntegrationResponse> => {
switch (block.type) {
case IntegrationBlockType.GOOGLE_SHEETS:
return executeGoogleSheetBlock(state, block)
case IntegrationBlockType.CHATWOOT:
return executeChatwootBlock(state, block)
case IntegrationBlockType.GOOGLE_ANALYTICS:
return executeGoogleAnalyticsBlock(state, block)
case IntegrationBlockType.EMAIL:
return executeSendEmailBlock(state, block)
case IntegrationBlockType.WEBHOOK:
case IntegrationBlockType.ZAPIER:
case IntegrationBlockType.MAKE_COM:
case IntegrationBlockType.PABBLY_CONNECT:
return executeWebhookBlock(state, block)
}
}

View File

@ -0,0 +1,24 @@
import { executeCode } from '@/features/blocks/logic/code/api'
import { executeCondition } from '@/features/blocks/logic/condition/api'
import { executeRedirect } from '@/features/blocks/logic/redirect/api'
import { executeSetVariable } from '@/features/blocks/logic/setVariable/api'
import { executeTypebotLink } from '@/features/blocks/logic/typebotLink/api'
import { LogicBlock, LogicBlockType, SessionState } from 'models'
import { ExecuteLogicResponse } from '../../types'
export const executeLogic =
(state: SessionState) =>
async (block: LogicBlock): Promise<ExecuteLogicResponse> => {
switch (block.type) {
case LogicBlockType.SET_VARIABLE:
return executeSetVariable(state, block)
case LogicBlockType.CONDITION:
return executeCondition(state, block)
case LogicBlockType.REDIRECT:
return executeRedirect(state, block)
case LogicBlockType.CODE:
return executeCode(state, block)
case LogicBlockType.TYPEBOT_LINK:
return executeTypebotLink(state, block)
}
}

View File

@ -0,0 +1,38 @@
import { byId } from 'utils'
import { Group, SessionState } from 'models'
export type NextGroup = {
group: Group
updatedContext?: SessionState
}
export const getNextGroup =
(state: SessionState) =>
(edgeId?: string): NextGroup | null => {
const { typebot } = state
const nextEdge = typebot.edges.find(byId(edgeId))
if (!nextEdge) {
if (state.linkedTypebots.queue.length > 0) {
const nextEdgeId = state.linkedTypebots.queue[0].edgeId
const updatedContext = {
...state,
linkedBotQueue: state.linkedTypebots.queue.slice(1),
}
const nextGroup = getNextGroup(updatedContext)(nextEdgeId)
if (!nextGroup) return null
return {
...nextGroup,
updatedContext,
}
}
return null
}
const nextGroup = typebot.groups.find(byId(nextEdge.to.groupId))
if (!nextGroup) return null
const startBlockIndex = nextEdge.to.blockId
? nextGroup.blocks.findIndex(byId(nextEdge.to.blockId))
: 0
return {
group: { ...nextGroup, blocks: nextGroup.blocks.slice(startBlockIndex) },
}
}

View File

@ -0,0 +1,12 @@
import prisma from '@/lib/prisma'
import { ChatSession } from 'models'
export const getSession = async (
sessionId: string
): Promise<Pick<ChatSession, 'state' | 'id'> | null> => {
const session = (await prisma.chatSession.findUnique({
where: { id: sessionId },
select: { id: true, state: true },
})) as Pick<ChatSession, 'state' | 'id'> | null
return session
}

View File

@ -0,0 +1,5 @@
export * from './continueBotFlow'
export * from './executeGroup'
export * from './getNextGroup'
export * from './getSessionState'
export * from './startBotFlow'

View File

@ -0,0 +1,13 @@
import { ChatReply, SessionState } from 'models'
import { executeGroup } from './executeGroup'
import { getNextGroup } from './getNextGroup'
export const startBotFlow = async (
state: SessionState
): Promise<ChatReply & { newSessionState?: SessionState }> => {
const firstEdgeId = state.typebot.groups[0].blocks[0].outgoingEdgeId
if (!firstEdgeId) return { messages: [] }
const nextGroup = getNextGroup(state)(firstEdgeId)
if (!nextGroup) return { messages: [] }
return executeGroup(state)(nextGroup.group)
}

View File

@ -0,0 +1,145 @@
import { getTestAsset } from '@/test/utils/playwright'
import test, { expect } from '@playwright/test'
import cuid from 'cuid'
import { HttpMethod } from 'models'
import {
createWebhook,
deleteTypebots,
deleteWebhooks,
importTypebotInDatabase,
} from 'utils/playwright/databaseActions'
const typebotId = cuid()
const publicId = `${typebotId}-public`
test.beforeEach(async () => {
await importTypebotInDatabase(getTestAsset('typebots/chat/main.json'), {
id: typebotId,
publicId,
})
await importTypebotInDatabase(getTestAsset('typebots/chat/linkedBot.json'), {
id: 'chat-sub-bot',
publicId: 'chat-sub-bot-public',
})
await createWebhook(typebotId, {
id: 'chat-webhook-id',
method: HttpMethod.GET,
url: 'https://api.chucknorris.io/jokes/random',
})
})
test.afterEach(async () => {
await deleteWebhooks(['chat-webhook-id'])
await deleteTypebots(['chat-sub-bot'])
})
test('API chat execution should work', async ({ request }) => {
let chatSessionId: string
await test.step('Start the chat', async () => {
const { sessionId, messages, input } = await (
await request.post(`/api/v1/typebots/${typebotId}/sendMessage`, {
data: {
message: 'Hi',
},
})
).json()
chatSessionId = sessionId
expect(sessionId).toBeDefined()
expect(messages[0].content.plainText).toBe('Hi there! 👋')
expect(messages[1].content.plainText).toBe("Welcome. What's your name?")
expect(input.type).toBe('text input')
})
await test.step('Answer Name question', async () => {
const { messages, input } = await (
await request.post(`/api/v1/typebots/${typebotId}/sendMessage`, {
data: { message: 'John', sessionId: chatSessionId },
})
).json()
expect(messages[0].content.plainText).toBe('Nice to meet you John')
expect(messages[1].content.url).toMatch(new RegExp('giphy.com', 'gm'))
expect(input.type).toBe('number input')
})
await test.step('Answer Age question', async () => {
const { messages, input } = await (
await request.post(`/api/v1/typebots/${typebotId}/sendMessage`, {
data: { message: '24', sessionId: chatSessionId },
})
).json()
expect(messages[0].content.plainText).toBe('Ok, you are an adult then 😁')
expect(messages[1].content.plainText).toBe('My magic number is 42')
expect(messages[2].content.plainText).toBe(
'How would you rate the experience so far?'
)
expect(input.type).toBe('rating input')
})
await test.step('Answer Rating question', async () => {
const { messages, input } = await (
await request.post(`/api/v1/typebots/${typebotId}/sendMessage`, {
data: { message: '8', sessionId: chatSessionId },
})
).json()
expect(messages[0].content.plainText).toBe(
"I'm gonna shoot multiple inputs now..."
)
expect(input.type).toBe('email input')
})
await test.step('Answer Email question with wrong input', async () => {
const { messages, input } = await (
await request.post(`/api/v1/typebots/${typebotId}/sendMessage`, {
data: { message: 'invalid email', sessionId: chatSessionId },
})
).json()
expect(messages[0].content.plainText).toBe(
"This email doesn't seem to be valid. Can you type it again?"
)
expect(input.type).toBe('email input')
})
await test.step('Answer Email question with valid input', async () => {
const { messages, input } = await (
await request.post(`/api/v1/typebots/${typebotId}/sendMessage`, {
data: { message: 'typebot@email.com', sessionId: chatSessionId },
})
).json()
expect(messages.length).toBe(0)
expect(input.type).toBe('url input')
})
await test.step('Answer URL question', async () => {
const { messages, input } = await (
await request.post(`/api/v1/typebots/${typebotId}/sendMessage`, {
data: { message: 'https://typebot.io', sessionId: chatSessionId },
})
).json()
expect(messages.length).toBe(0)
expect(input.type).toBe('choice input')
})
await test.step('Answer Buttons question with invalid choice', async () => {
const { messages, input } = await (
await request.post(`/api/v1/typebots/${typebotId}/sendMessage`, {
data: { message: 'Yolo', sessionId: chatSessionId },
})
).json()
expect(messages[0].content.plainText).toBe(
'Invalid message. Please, try again.'
)
expect(input.type).toBe('choice input')
})
await test.step('Answer Buttons question with invalid choice', async () => {
const { messages } = await (
await request.post(`/api/v1/typebots/${typebotId}/sendMessage`, {
data: { message: 'Yes', sessionId: chatSessionId },
})
).json()
expect(messages[0].content.plainText).toBe('Ok, you are solid 👏')
expect(messages[1].content.plainText).toBe("Let's trigger a webhook...")
expect(messages[2].content.plainText.length).toBeGreaterThan(0)
})
})

View File

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

View File

@ -0,0 +1,13 @@
import { ChatReply, SessionState } from 'models'
export type EdgeId = string
export type ExecuteLogicResponse = {
outgoingEdgeId: EdgeId | undefined
newSessionState?: SessionState
} & Pick<ChatReply, 'logic'>
export type ExecuteIntegrationResponse = {
outgoingEdgeId: EdgeId | undefined
newSessionState?: SessionState
} & Pick<ChatReply, 'integrations'>

View File

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

View File

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

View File

@ -1,4 +1,5 @@
import prisma from '@/lib/prisma'
import { isNotDefined } from 'utils'
export const saveLog = (
status: 'error' | 'success',
@ -12,12 +13,13 @@ export const saveLog = (
resultId,
status,
description: message,
details: formatDetails(details) as string,
details: formatDetails(details) as string | null,
},
})
}
const formatDetails = (details: unknown) => {
if (isNotDefined(details)) return null
try {
return JSON.stringify(details, null, 2).substring(0, 1000)
} catch {

View File

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

View File

@ -0,0 +1,8 @@
import prisma from '@/lib/prisma'
import { ResultValues } from 'models'
export const getResultValues = async (resultId: string) =>
(await prisma.result.findUnique({
where: { id: resultId },
include: { answers: true },
})) as ResultValues | null

View File

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

View File

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

View File

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

View File

@ -0,0 +1,163 @@
import prisma from '@/lib/prisma'
import {
SessionState,
Variable,
VariableWithUnknowValue,
VariableWithValue,
} from 'models'
import { isDefined, isNotDefined } from 'utils'
export const stringContainsVariable = (str: string): boolean =>
/\{\{(.*?)\}\}/g.test(str)
export const parseVariables =
(
variables: Variable[],
options: { fieldToParse?: 'value' | 'id'; escapeForJson?: boolean } = {
fieldToParse: 'value',
escapeForJson: false,
}
) =>
(text: string | undefined): string => {
if (!text || text === '') return ''
return text.replace(/\{\{(.*?)\}\}/g, (_, fullVariableString) => {
const matchedVarName = fullVariableString.replace(/{{|}}/g, '')
const variable = variables.find((v) => {
return matchedVarName === v.name && isDefined(v.value)
}) as VariableWithValue | undefined
if (!variable || variable.value === null) return ''
if (options.fieldToParse === 'id') return variable.id
const { value } = variable
if (options.escapeForJson) return jsonParse(value)
const parsedValue = safeStringify(value)
if (!parsedValue) return ''
return parsedValue
})
}
export const extractVariablesFromText =
(variables: Variable[]) =>
(text: string): Variable[] => {
const matches = [...text.matchAll(/\{\{(.*?)\}\}/g)]
return matches.reduce<Variable[]>((acc, match) => {
const variableName = match[1]
const variable = variables.find(
(variable) => variable.name === variableName
)
if (!variable) return acc
return [...acc, variable]
}, [])
}
export const safeStringify = (val: unknown): string | null => {
if (isNotDefined(val)) return null
if (typeof val === 'string') return val
try {
return JSON.stringify(val)
} catch {
console.warn('Failed to safely stringify variable value', val)
return null
}
}
export const parseCorrectValueType = (
value: Variable['value']
): string | boolean | number | null | undefined => {
if (value === null) return null
if (value === undefined) return undefined
const isNumberStartingWithZero =
value.startsWith('0') && !value.startsWith('0.') && value.length > 1
if (typeof value === 'string' && isNumberStartingWithZero) return value
if (typeof value === 'number') return value
if (value === 'true') return true
if (value === 'false') return false
if (value === 'null') return null
if (value === 'undefined') return undefined
// isNaN works with strings
if (isNaN(value as unknown as number)) return value
return Number(value)
}
const jsonParse = (str: string) =>
str
.replace(/\n/g, `\\n`)
.replace(/"/g, `\\"`)
.replace(/\\[^n"]/g, `\\\\ `)
export const parseVariablesInObject = (
object: { [key: string]: string | number },
variables: Variable[]
) =>
Object.keys(object).reduce((newObj, key) => {
const currentValue = object[key]
return {
...newObj,
[key]:
typeof currentValue === 'string'
? parseVariables(variables)(currentValue)
: currentValue,
}
}, {})
export const updateVariables =
(state: SessionState) =>
async (newVariables: VariableWithUnknowValue[]): Promise<SessionState> => ({
...state,
typebot: {
...state.typebot,
variables: updateTypebotVariables(state)(newVariables),
},
result: {
...state.result,
variables: await updateResultVariables(state)(newVariables),
},
})
const updateResultVariables =
({ result }: Pick<SessionState, 'result' | 'typebot'>) =>
async (
newVariables: VariableWithUnknowValue[]
): Promise<VariableWithValue[]> => {
const serializedNewVariables = newVariables.map((variable) => ({
...variable,
value: safeStringify(variable.value),
}))
const updatedVariables = [
...result.variables.filter((existingVariable) =>
serializedNewVariables.every(
(newVariable) => existingVariable.id !== newVariable.id
)
),
...serializedNewVariables,
].filter((variable) => isDefined(variable.value)) as VariableWithValue[]
await prisma.result.update({
where: {
id: result.id,
},
data: {
variables: updatedVariables,
},
})
return updatedVariables
}
const updateTypebotVariables =
({ typebot }: Pick<SessionState, 'result' | 'typebot'>) =>
(newVariables: VariableWithUnknowValue[]): Variable[] => {
const serializedNewVariables = newVariables.map((variable) => ({
...variable,
value: safeStringify(variable.value),
}))
return [
...typebot.variables.filter((existingVariable) =>
serializedNewVariables.every(
(newVariable) => existingVariable.id !== newVariable.id
)
),
...serializedNewVariables,
]
}

View File

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

View File

@ -39,7 +39,10 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
(row) => row[referenceCell.column as string] === referenceCell.value
)
if (!row) {
await saveErrorLog(resultId, "Couldn't find reference cell")
await saveErrorLog({
resultId,
message: "Couldn't find reference cell",
})
return res.status(404).send({ message: "Couldn't find row" })
}
const response = {
@ -48,10 +51,17 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
{}
),
}
await saveSuccessLog(resultId, 'Succesfully fetched spreadsheet data')
await saveSuccessLog({
resultId,
message: 'Succesfully fetched spreadsheet data',
})
return res.send(response)
} catch (err) {
await saveErrorLog(resultId, "Couldn't fetch spreadsheet data", err)
await saveErrorLog({
resultId,
message: "Couldn't fetch spreadsheet data",
details: err,
})
return res.status(500).send(err)
}
}
@ -74,10 +84,14 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
await doc.loadInfo()
const sheet = doc.sheetsById[sheetId]
await sheet.addRow(values)
await saveSuccessLog(resultId, 'Succesfully inserted row')
await saveSuccessLog({ resultId, message: 'Succesfully inserted row' })
return res.send({ message: 'Success' })
} catch (err) {
await saveErrorLog(resultId, "Couldn't fetch spreadsheet data", err)
await saveErrorLog({
resultId,
message: "Couldn't fetch spreadsheet data",
details: err,
})
return res.status(500).send(err)
}
}
@ -110,10 +124,14 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
rows[updatingRowIndex][key] = values[key]
}
await rows[updatingRowIndex].save()
await saveSuccessLog(resultId, 'Succesfully updated row')
await saveSuccessLog({ resultId, message: 'Succesfully updated row' })
return res.send({ message: 'Success' })
} catch (err) {
await saveErrorLog(resultId, "Couldn't fetch spreadsheet data", err)
await saveErrorLog({
resultId,
message: "Couldn't fetch spreadsheet data",
details: err,
})
return res.status(500).send(err)
}
}

View File

@ -20,9 +20,9 @@ import { stringify } from 'qs'
import { withSentry } from '@sentry/nextjs'
import Cors from 'cors'
import prisma from '@/lib/prisma'
import { getLinkedTypebots } from '@/features/typebotLink/api'
import { saveErrorLog, saveSuccessLog } from '@/features/logs/api'
import { parseSampleResult } from '@/features/webhook/api'
import { parseSampleResult } from '@/features/blocks/integrations/webhook/api'
import { getLinkedTypebots } from '@/features/blocks/logic/typebotLink/api'
const cors = initMiddleware(Cors())
@ -149,10 +149,14 @@ export const executeWebhook =
}
try {
const response = await got(request.url, omit(request, 'url'))
await saveSuccessLog(resultId, 'Webhook successfuly executed.', {
statusCode: response.statusCode,
request,
response: safeJsonParse(response.body).data,
await saveSuccessLog({
resultId,
message: 'Webhook successfuly executed.',
details: {
statusCode: response.statusCode,
request,
response: safeJsonParse(response.body).data,
},
})
return {
statusCode: response.statusCode,
@ -164,9 +168,13 @@ export const executeWebhook =
statusCode: error.response.statusCode,
data: safeJsonParse(error.response.body as string).data,
}
await saveErrorLog(resultId, 'Webhook returned an error', {
request,
response,
await saveErrorLog({
resultId,
message: 'Webhook returned an error',
details: {
request,
response,
},
})
return response
}
@ -175,9 +183,13 @@ export const executeWebhook =
data: { message: `Error from Typebot server: ${error}` },
}
console.error(error)
await saveErrorLog(resultId, 'Webhook failed to execute', {
request,
response,
await saveErrorLog({
resultId,
message: 'Webhook failed to execute',
details: {
request,
response,
},
})
return response
}

View File

@ -1,6 +1,6 @@
import { authenticateUser } from '@/features/auth/api'
import { getLinkedTypebots } from '@/features/typebotLink/api'
import { parseSampleResult } from '@/features/webhook/api'
import { getLinkedTypebots } from '@/features/blocks/logic/typebotLink/api'
import { parseSampleResult } from '@/features/blocks/integrations/webhook/api'
import prisma from '@/lib/prisma'
import { Typebot } from 'models'
import { NextApiRequest, NextApiResponse } from 'next'

View File

@ -1,10 +1,10 @@
import { authenticateUser } from '@/features/auth/api'
import { getLinkedTypebots } from '@/features/typebotLink/api'
import { getLinkedTypebots } from '@/features/blocks/logic/typebotLink/api'
import { parseSampleResult } from '@/features/blocks/integrations/webhook/api'
import prisma from '@/lib/prisma'
import { Typebot } from 'models'
import { NextApiRequest, NextApiResponse } from 'next'
import { methodNotAllowed } from 'utils/api'
import { parseSampleResult } from '@/features/webhook/api'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const user = await authenticateUser(req)

View File

@ -16,7 +16,7 @@ import Mail from 'nodemailer/lib/mailer'
import { DefaultBotNotificationEmail } from 'emails'
import { render } from '@faire/mjml-react/dist/src/utils/render'
import prisma from '@/lib/prisma'
import { getLinkedTypebots } from '@/features/typebotLink/api'
import { getLinkedTypebots } from '@/features/blocks/logic/typebotLink/api'
const cors = initMiddleware(Cors())
@ -84,14 +84,18 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
})
if (!emailBody) {
await saveErrorLog(resultId, 'Email not sent', {
transportConfig,
recipients,
subject,
cc,
bcc,
replyTo,
emailBody,
await saveErrorLog({
resultId,
message: 'Email not sent',
details: {
transportConfig,
recipients,
subject,
cc,
bcc,
replyTo,
emailBody,
},
})
return res.status(404).send({ message: "Couldn't find email body" })
}
@ -109,12 +113,16 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
}
try {
const info = await transporter.sendMail(email)
await saveSuccessLog(resultId, 'Email successfully sent', {
transportConfig: {
...transportConfig,
auth: { user: transportConfig.auth.user, pass: '******' },
await saveSuccessLog({
resultId,
message: 'Email successfully sent',
details: {
transportConfig: {
...transportConfig,
auth: { user: transportConfig.auth.user, pass: '******' },
},
email,
},
email,
})
return res.status(200).send({
message: 'Email sent!',
@ -122,13 +130,17 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
previewUrl: getTestMessageUrl(info),
})
} catch (err) {
await saveErrorLog(resultId, 'Email not sent', {
transportConfig: {
...transportConfig,
auth: { user: transportConfig.auth.user, pass: '******' },
await saveErrorLog({
resultId,
message: 'Email not sent',
details: {
transportConfig: {
...transportConfig,
auth: { user: transportConfig.auth.user, pass: '******' },
},
email,
error: err,
},
email,
error: err,
})
return res.status(500).send({
message: `Email not sent. Error: ${err}`,

View File

@ -0,0 +1,13 @@
import { appRouter } from '@/utils/server/routers/v1/_app'
import { captureException } from '@sentry/nextjs'
import { createOpenApiNextHandler } from 'trpc-openapi'
export default createOpenApiNextHandler({
router: appRouter,
onError({ error }) {
if (error.code === 'INTERNAL_SERVER_ERROR') {
captureException(error)
console.error('Something went wrong', error)
}
},
})

View File

@ -0,0 +1,107 @@
{
"id": "chat-sub-bot",
"createdAt": "2022-11-24T09:06:52.903Z",
"updatedAt": "2022-11-24T09:13:16.782Z",
"icon": "👶",
"name": "Sub bot",
"publishedTypebotId": null,
"folderId": null,
"groups": [
{
"id": "clauup2lh0002vs1a5ei32mmi",
"title": "Start",
"blocks": [
{
"id": "clauup2li0003vs1aas14fwpc",
"type": "start",
"label": "Start",
"groupId": "clauup2lh0002vs1a5ei32mmi",
"outgoingEdgeId": "clauupl9n001b3b6qdk4czgom"
}
],
"graphCoordinates": { "x": 0, "y": 0 }
},
{
"id": "clauupd6q00183b6qcm8qbz62",
"title": "Group #1",
"blocks": [
{
"id": "clauupd6q00193b6qhegmlnxj",
"type": "text",
"content": {
"html": "<div>How would you rate the experience so far?</div>",
"richText": [
{
"type": "p",
"children": [
{ "text": "How would you rate the experience so far?" }
]
}
],
"plainText": "How would you rate the experience so far?"
},
"groupId": "clauupd6q00183b6qcm8qbz62"
},
{
"id": "clauupk97001a3b6q2w9qqkec",
"type": "rating input",
"groupId": "clauupd6q00183b6qcm8qbz62",
"options": {
"labels": { "button": "Send" },
"length": 10,
"buttonType": "Numbers",
"customIcon": { "isEnabled": false }
}
}
],
"graphCoordinates": { "x": 375.36328125, "y": 167.2578125 }
}
],
"variables": [],
"edges": [
{
"id": "clauupl9n001b3b6qdk4czgom",
"to": { "groupId": "clauupd6q00183b6qcm8qbz62" },
"from": {
"blockId": "clauup2li0003vs1aas14fwpc",
"groupId": "clauup2lh0002vs1a5ei32mmi"
}
}
],
"theme": {
"chat": {
"inputs": {
"color": "#303235",
"backgroundColor": "#FFFFFF",
"placeholderColor": "#9095A0"
},
"buttons": { "color": "#FFFFFF", "backgroundColor": "#0042DA" },
"hostAvatar": {
"url": "https://avatars.githubusercontent.com/u/16015833?v=4",
"isEnabled": true
},
"hostBubbles": { "color": "#303235", "backgroundColor": "#F7F8FF" },
"guestBubbles": { "color": "#FFFFFF", "backgroundColor": "#FF8E21" }
},
"general": { "font": "Open Sans", "background": { "type": "None" } }
},
"settings": {
"general": {
"isBrandingEnabled": false,
"isInputPrefillEnabled": true,
"isResultSavingEnabled": true,
"isHideQueryParamsEnabled": true,
"isNewResultOnRefreshEnabled": false
},
"metadata": {
"description": "Build beautiful conversational forms and embed them directly in your applications without a line of code. Triple your response rate and collect answers that has more value compared to a traditional form."
},
"typingEmulation": { "speed": 300, "enabled": true, "maxDelay": 1.5 }
},
"publicId": null,
"customDomain": null,
"workspaceId": "proWorkspace",
"resultsTablePreferences": null,
"isArchived": false,
"isClosed": false
}

View File

@ -0,0 +1,515 @@
{
"id": "clauujawp00011avs2vj97zma",
"createdAt": "2022-11-24T09:02:23.737Z",
"updatedAt": "2022-11-24T09:12:57.036Z",
"icon": "🤖",
"name": "Complete bot",
"publishedTypebotId": null,
"folderId": null,
"groups": [
{
"id": "clauujawn0000vs1a8z6k2k7d",
"title": "Start",
"blocks": [
{
"id": "clauujawn0001vs1a0mk8docp",
"type": "start",
"label": "Start",
"groupId": "clauujawn0000vs1a8z6k2k7d",
"outgoingEdgeId": "clauuk4o300083b6q7b2iowv3"
}
],
"graphCoordinates": { "x": 0, "y": 0 }
},
{
"id": "clauujxdc00063b6q42ca20gj",
"title": "Welcome",
"blocks": [
{
"id": "clauujxdd00073b6qpejnkzcy",
"type": "text",
"content": {
"html": "<div>Hi there! 👋</div>",
"richText": [
{ "type": "p", "children": [{ "text": "Hi there! 👋" }] }
],
"plainText": "Hi there! 👋"
},
"groupId": "clauujxdc00063b6q42ca20gj"
},
{
"id": "clauukaad00093b6q07av51yc",
"type": "text",
"content": {
"html": "<div>Welcome. What&apos;s your name?</div>",
"richText": [
{
"type": "p",
"children": [{ "text": "Welcome. What's your name?" }]
}
],
"plainText": "Welcome. What's your name?"
},
"groupId": "clauujxdc00063b6q42ca20gj"
},
{
"id": "clauukip8000a3b6qtzl288tu",
"type": "text input",
"groupId": "clauujxdc00063b6q42ca20gj",
"options": {
"isLong": false,
"labels": {
"button": "Send",
"placeholder": "Type your answer..."
},
"variableId": "vclauuklnc000b3b6q7xchq4yf"
},
"outgoingEdgeId": "clauul0sk000f3b6q2tvy5wfi"
}
],
"graphCoordinates": { "x": 5.81640625, "y": 172.359375 }
},
{
"id": "clauukoka000c3b6qe6chawis",
"title": "Age",
"blocks": [
{
"id": "clauukoka000d3b6qxqi38cmk",
"type": "text",
"content": {
"html": "<div>Nice to meet you {{Name}}</div>",
"richText": [
{
"type": "p",
"children": [{ "text": "Nice to meet you {{Name}}" }]
}
],
"plainText": "Nice to meet you {{Name}}"
},
"groupId": "clauukoka000c3b6qe6chawis"
},
{
"id": "clauuku5o000e3b6q90rm30p1",
"type": "image",
"content": {
"url": "https://media2.giphy.com/media/l0MYGb1LuZ3n7dRnO/giphy-downsized.gif?cid=fe3852a3yd2leg4yi8iual3wgyw893zzocuuqlp3wytt802h&rid=giphy-downsized.gif&ct=g"
},
"groupId": "clauukoka000c3b6qe6chawis"
},
{
"id": "clauul4vg000g3b6qr0q2h0uy",
"type": "text",
"content": {
"html": "<div>How old are you?</div>",
"richText": [
{ "type": "p", "children": [{ "text": "How old are you?" }] }
],
"plainText": "How old are you?"
},
"groupId": "clauukoka000c3b6qe6chawis"
},
{
"id": "clauul90j000h3b6qjfrw9js4",
"type": "number input",
"groupId": "clauukoka000c3b6qe6chawis",
"options": {
"labels": { "button": "Send", "placeholder": "Type a number..." },
"variableId": "vclauulfjk000i3b6qmujooweu"
},
"outgoingEdgeId": "clauum41j000n3b6qpqu12icm"
}
],
"graphCoordinates": { "x": 361.17578125, "y": 170.10546875 }
},
{
"id": "clauulhqf000j3b6qm8y5oifc",
"title": "Is major?",
"blocks": [
{
"id": "clauulhqf000k3b6qsrc1hd74",
"type": "Condition",
"items": [
{
"id": "clauulhqg000l3b6qaxn4qli5",
"type": 1,
"blockId": "clauulhqf000k3b6qsrc1hd74",
"content": {
"comparisons": [
{
"id": "clauuliyn000m3b6q10gwx8ii",
"value": "21",
"variableId": "vclauulfjk000i3b6qmujooweu",
"comparisonOperator": "Greater than"
}
],
"logicalOperator": "AND"
},
"outgoingEdgeId": "clauumi0x000q3b6q9bwkqnmr"
}
],
"groupId": "clauulhqf000j3b6qm8y5oifc",
"outgoingEdgeId": "clauumm5v000t3b6qu62qcft8"
}
],
"graphCoordinates": { "x": 726.2265625, "y": 240.80078125 }
},
{
"id": "clauum8x7000o3b6qx8hqduf8",
"title": "Group #4",
"blocks": [
{
"id": "clauum8x7000p3b6qxjud5hdc",
"type": "text",
"content": {
"html": "<div>Ok, you are an adult then 😁</div>",
"richText": [
{
"type": "p",
"children": [{ "text": "Ok, you are an adult then 😁" }]
}
],
"plainText": "Ok, you are an adult then 😁"
},
"groupId": "clauum8x7000o3b6qx8hqduf8",
"outgoingEdgeId": "clauuom2y000y3b6qkcjy2ri7"
}
],
"graphCoordinates": { "x": 1073.38671875, "y": 232.25 }
},
{
"id": "clauumjq4000r3b6q8l6bi9ra",
"title": "Group #4 copy",
"blocks": [
{
"id": "clauumjq5000s3b6qqjhrklv4",
"type": "text",
"content": {
"html": "<div>Oh, you are a kid 😁</div>",
"richText": [
{ "type": "p", "children": [{ "text": "Oh, you are a kid 😁" }] }
],
"plainText": "Oh, you are a kid 😁"
},
"groupId": "clauumjq4000r3b6q8l6bi9ra",
"outgoingEdgeId": "clauuol8t000x3b6qcw1few70"
}
],
"graphCoordinates": { "x": 1073.984375, "y": 408.6875 }
},
{
"id": "clauuoekh000u3b6q6zmlx7f9",
"title": "Magic number",
"blocks": [
{
"id": "clauuoeki000v3b6qvsh7kde1",
"type": "Set variable",
"groupId": "clauuoekh000u3b6q6zmlx7f9",
"options": {
"variableId": "vclauuohyp000w3b6qbqrs6c6w",
"expressionToEvaluate": "42"
}
},
{
"id": "clauuontu000z3b6q3ydx6ao1",
"type": "text",
"content": {
"html": "<div>My magic number is {{Magic number}}</div>",
"richText": [
{
"type": "p",
"children": [{ "text": "My magic number is {{Magic number}}" }]
}
],
"plainText": "My magic number is {{Magic number}}"
},
"groupId": "clauuoekh000u3b6q6zmlx7f9",
"outgoingEdgeId": "clauuq8je001e3b6qksm4j11g"
}
],
"graphCoordinates": { "x": 1465.359375, "y": 299.25390625 }
},
{
"id": "clauuq2l6001c3b6qpmq3ivwk",
"graphCoordinates": { "x": 1836.43359375, "y": 295.39453125 },
"title": "Rate the experience",
"blocks": [
{
"id": "clauuq2l6001d3b6qyltfcvgb",
"groupId": "clauuq2l6001c3b6qpmq3ivwk",
"type": "Typebot link",
"options": {
"typebotId": "chat-sub-bot",
"groupId": "clauupd6q00183b6qcm8qbz62"
},
"outgoingEdgeId": "clauureo3001h3b6qk6epabxq"
}
]
},
{
"id": "clauur7od001f3b6qq140oe55",
"graphCoordinates": { "x": 2201.45703125, "y": 299.1328125 },
"title": "Multiple input in group",
"blocks": [
{
"id": "clauur7od001g3b6qkoeij3f7",
"groupId": "clauur7od001f3b6qq140oe55",
"type": "text",
"content": {
"html": "<div>I&apos;m gonna shoot multiple inputs now...</div>",
"richText": [
{
"type": "p",
"children": [
{ "text": "I'm gonna shoot multiple inputs now..." }
]
}
],
"plainText": "I'm gonna shoot multiple inputs now..."
}
},
{
"id": "clauurluf001i3b6qjf78puug",
"groupId": "clauur7od001f3b6qq140oe55",
"type": "email input",
"options": {
"labels": { "button": "Send", "placeholder": "Type your email..." },
"retryMessageContent": "This email doesn't seem to be valid. Can you type it again?"
}
},
{
"id": "clauurokp001j3b6qyrw7boca",
"groupId": "clauur7od001f3b6qq140oe55",
"type": "url input",
"options": {
"labels": { "button": "Send", "placeholder": "Type a URL..." },
"retryMessageContent": "This URL doesn't seem to be valid. Can you type it again?"
}
},
{
"id": "clauurs1o001k3b6qgrj0xf59",
"groupId": "clauur7od001f3b6qq140oe55",
"type": "choice input",
"options": { "buttonLabel": "Send", "isMultipleChoice": false },
"items": [
{
"id": "clauurs1o001l3b6qu9hr712h",
"blockId": "clauurs1o001k3b6qgrj0xf59",
"type": 0,
"content": "Yes"
},
{
"id": "clauuru6t001m3b6qp8vkt23l",
"content": "No",
"blockId": "clauurs1o001k3b6qgrj0xf59",
"type": 0
}
],
"outgoingEdgeId": "clauushy3001p3b6qqnyrxgtb"
}
]
},
{
"id": "clauusa9z001n3b6qys3xvz1l",
"graphCoordinates": { "x": 2558.609375, "y": 297.078125 },
"title": "Get Chuck Norris joke",
"blocks": [
{
"id": "clauusaa0001o3b6qgddldaen",
"groupId": "clauusa9z001n3b6qys3xvz1l",
"type": "text",
"content": {
"html": "<div>Ok, you are solid 👏</div>",
"richText": [
{ "type": "p", "children": [{ "text": "Ok, you are solid 👏" }] }
],
"plainText": "Ok, you are solid 👏"
}
},
{
"id": "clauusrfh001q3b6q7xaapi4h",
"groupId": "clauusa9z001n3b6qys3xvz1l",
"type": "text",
"content": {
"html": "<div>Let&apos;s trigger a webhook...</div>",
"richText": [
{
"type": "p",
"children": [{ "text": "Let's trigger a webhook..." }]
}
],
"plainText": "Let's trigger a webhook..."
}
},
{
"id": "clauut2nq001r3b6qi437ixc7",
"groupId": "clauusa9z001n3b6qys3xvz1l",
"type": "Webhook",
"options": {
"responseVariableMapping": [
{
"id": "clauuvvdr001t3b6qqdxzc057",
"bodyPath": "data.value",
"variableId": "vclauuwchv001u3b6qepx6e0a9"
}
],
"variablesForTest": [],
"isAdvancedConfig": true,
"isCustomBody": false
},
"webhookId": "chat-webhook-id",
"outgoingEdgeId": "clauuwjq2001x3b6qciu53855"
}
]
},
{
"id": "clauuwhyl001v3b6qarbpiqbv",
"graphCoordinates": { "x": 2900.9609375, "y": 288.29296875 },
"title": "Display joke",
"blocks": [
{
"id": "clauuwhyl001w3b6q7ai0zeyt",
"groupId": "clauuwhyl001v3b6qarbpiqbv",
"type": "text",
"content": {
"html": "<div>{{Joke}}</div>",
"richText": [{ "type": "p", "children": [{ "text": "{{Joke}}" }] }],
"plainText": "{{Joke}}"
}
}
]
}
],
"variables": [
{ "id": "vclauuklnc000b3b6q7xchq4yf", "name": "Name" },
{ "id": "vclauulfjk000i3b6qmujooweu", "name": "Age" },
{ "id": "vclauuohyp000w3b6qbqrs6c6w", "name": "Magic number" },
{ "id": "vclauuwchv001u3b6qepx6e0a9", "name": "Joke" }
],
"edges": [
{
"id": "clauuk4o300083b6q7b2iowv3",
"to": { "groupId": "clauujxdc00063b6q42ca20gj" },
"from": {
"blockId": "clauujawn0001vs1a0mk8docp",
"groupId": "clauujawn0000vs1a8z6k2k7d"
}
},
{
"id": "clauul0sk000f3b6q2tvy5wfi",
"to": { "groupId": "clauukoka000c3b6qe6chawis" },
"from": {
"blockId": "clauukip8000a3b6qtzl288tu",
"groupId": "clauujxdc00063b6q42ca20gj"
}
},
{
"id": "clauum41j000n3b6qpqu12icm",
"to": { "groupId": "clauulhqf000j3b6qm8y5oifc" },
"from": {
"blockId": "clauul90j000h3b6qjfrw9js4",
"groupId": "clauukoka000c3b6qe6chawis"
}
},
{
"id": "clauumi0x000q3b6q9bwkqnmr",
"to": { "groupId": "clauum8x7000o3b6qx8hqduf8" },
"from": {
"itemId": "clauulhqg000l3b6qaxn4qli5",
"blockId": "clauulhqf000k3b6qsrc1hd74",
"groupId": "clauulhqf000j3b6qm8y5oifc"
}
},
{
"id": "clauumm5v000t3b6qu62qcft8",
"to": { "groupId": "clauumjq4000r3b6q8l6bi9ra" },
"from": {
"blockId": "clauulhqf000k3b6qsrc1hd74",
"groupId": "clauulhqf000j3b6qm8y5oifc"
}
},
{
"id": "clauuol8t000x3b6qcw1few70",
"to": { "groupId": "clauuoekh000u3b6q6zmlx7f9" },
"from": {
"blockId": "clauumjq5000s3b6qqjhrklv4",
"groupId": "clauumjq4000r3b6q8l6bi9ra"
}
},
{
"id": "clauuom2y000y3b6qkcjy2ri7",
"to": { "groupId": "clauuoekh000u3b6q6zmlx7f9" },
"from": {
"blockId": "clauum8x7000p3b6qxjud5hdc",
"groupId": "clauum8x7000o3b6qx8hqduf8"
}
},
{
"from": {
"groupId": "clauuoekh000u3b6q6zmlx7f9",
"blockId": "clauuontu000z3b6q3ydx6ao1"
},
"to": { "groupId": "clauuq2l6001c3b6qpmq3ivwk" },
"id": "clauuq8je001e3b6qksm4j11g"
},
{
"from": {
"groupId": "clauuq2l6001c3b6qpmq3ivwk",
"blockId": "clauuq2l6001d3b6qyltfcvgb"
},
"to": { "groupId": "clauur7od001f3b6qq140oe55" },
"id": "clauureo3001h3b6qk6epabxq"
},
{
"from": {
"groupId": "clauur7od001f3b6qq140oe55",
"blockId": "clauurs1o001k3b6qgrj0xf59"
},
"to": { "groupId": "clauusa9z001n3b6qys3xvz1l" },
"id": "clauushy3001p3b6qqnyrxgtb"
},
{
"from": {
"groupId": "clauusa9z001n3b6qys3xvz1l",
"blockId": "clauut2nq001r3b6qi437ixc7"
},
"to": { "groupId": "clauuwhyl001v3b6qarbpiqbv" },
"id": "clauuwjq2001x3b6qciu53855"
}
],
"theme": {
"chat": {
"inputs": {
"color": "#303235",
"backgroundColor": "#FFFFFF",
"placeholderColor": "#9095A0"
},
"buttons": { "color": "#FFFFFF", "backgroundColor": "#0042DA" },
"hostAvatar": {
"url": "https://avatars.githubusercontent.com/u/16015833?v=4",
"isEnabled": true
},
"hostBubbles": { "color": "#303235", "backgroundColor": "#F7F8FF" },
"guestBubbles": { "color": "#FFFFFF", "backgroundColor": "#FF8E21" }
},
"general": { "font": "Open Sans", "background": { "type": "None" } }
},
"settings": {
"general": {
"isBrandingEnabled": false,
"isInputPrefillEnabled": true,
"isResultSavingEnabled": true,
"isHideQueryParamsEnabled": true,
"isNewResultOnRefreshEnabled": false
},
"metadata": {
"description": "Build beautiful conversational forms and embed them directly in your applications without a line of code. Triple your response rate and collect answers that has more value compared to a traditional form."
},
"typingEmulation": { "speed": 300, "enabled": true, "maxDelay": 1.5 }
},
"publicId": null,
"customDomain": null,
"workspaceId": "proWorkspace",
"resultsTablePreferences": null,
"isArchived": false,
"isClosed": false
}

View File

@ -0,0 +1,15 @@
import { generateOpenApiDocument } from 'trpc-openapi'
import { writeFileSync } from 'fs'
import { appRouter } from './routers/v1/_app'
const openApiDocument = generateOpenApiDocument(appRouter, {
title: 'Chat API',
version: '1.0.0',
baseUrl: 'https://typebot.io/api/v1',
docsUrl: 'https://docs.typebot.io/api',
})
writeFileSync(
'./openapi/chat/_spec_.json',
JSON.stringify(openApiDocument, null, 2)
)

View File

@ -0,0 +1,8 @@
import { chatRouter } from '@/features/chat/api'
import { router } from '../../trpc'
export const appRouter = router({
chat: chatRouter,
})
export type AppRouter = typeof appRouter

View File

@ -0,0 +1,13 @@
import { initTRPC } from '@trpc/server'
import { OpenApiMeta } from 'trpc-openapi'
import superjson from 'superjson'
const t = initTRPC.meta<OpenApiMeta>().create({
transformer: superjson,
})
export const middleware = t.middleware
export const router = t.router
export const publicProcedure = t.procedure