⚗️ Implement chat API
This commit is contained in:
@ -1,4 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
const { withSentryConfig } = require('@sentry/nextjs')
|
||||
const path = require('path')
|
||||
const withTM = require('next-transpile-modules')([
|
||||
|
@ -19,7 +19,6 @@ export const getAuthenticatedUser = async (
|
||||
const authenticateByToken = async (
|
||||
apiToken: string
|
||||
): Promise<User | undefined> => {
|
||||
console.log(window)
|
||||
if (typeof window !== 'undefined') return
|
||||
return (await prisma.user.findFirst({
|
||||
where: { apiTokens: { some: { token: apiToken } } },
|
||||
|
@ -30,6 +30,7 @@ export const parseNewTypebot = ({
|
||||
| 'icon'
|
||||
| 'isArchived'
|
||||
| 'isClosed'
|
||||
| 'resultsTablePreferences'
|
||||
> => {
|
||||
const startGroupId = cuid()
|
||||
const startBlockId = cuid()
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { duplicateWebhookQueries } from '@/features/blocks/integrations/webhook'
|
||||
import cuid from 'cuid'
|
||||
import { Plan } from 'db'
|
||||
import { Plan, Prisma } from 'db'
|
||||
import {
|
||||
ChoiceInputBlock,
|
||||
ConditionBlock,
|
||||
@ -38,7 +38,10 @@ export const importTypebotQuery = async (typebot: Typebot, userPlan: Plan) => {
|
||||
const duplicateTypebot = (
|
||||
typebot: Typebot,
|
||||
userPlan: Plan
|
||||
): { typebot: Typebot; webhookIdsMapping: Map<string, string> } => {
|
||||
): {
|
||||
typebot: Omit<Prisma.TypebotUncheckedCreateInput, 'id'> & { id: string }
|
||||
webhookIdsMapping: Map<string, string>
|
||||
} => {
|
||||
const groupIdsMapping = generateOldNewIdsMapping(typebot.groups)
|
||||
const edgeIdsMapping = generateOldNewIdsMapping(typebot.edges)
|
||||
const webhookIdsMapping = generateOldNewIdsMapping(
|
||||
@ -119,8 +122,8 @@ const duplicateTypebot = (
|
||||
general: { ...typebot.settings.general, isBrandingEnabled: true },
|
||||
}
|
||||
: typebot.settings,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
resultsTablePreferences: typebot.resultsTablePreferences ?? undefined,
|
||||
},
|
||||
webhookIdsMapping,
|
||||
|
@ -11,7 +11,7 @@ enum ActionType {
|
||||
Flush = 'FLUSH',
|
||||
}
|
||||
|
||||
export interface Actions<T extends { updatedAt: string } | undefined> {
|
||||
export interface Actions<T extends { updatedAt: Date } | undefined> {
|
||||
set: (
|
||||
newPresent: T | ((current: T) => T),
|
||||
options?: { updateDate: boolean }
|
||||
@ -24,13 +24,13 @@ export interface Actions<T extends { updatedAt: string } | undefined> {
|
||||
presentRef: React.MutableRefObject<T>
|
||||
}
|
||||
|
||||
interface Action<T extends { updatedAt: string } | undefined> {
|
||||
interface Action<T extends { updatedAt: Date } | undefined> {
|
||||
type: ActionType
|
||||
newPresent?: T
|
||||
updateDate?: boolean
|
||||
}
|
||||
|
||||
export interface State<T extends { updatedAt: string } | undefined> {
|
||||
export interface State<T extends { updatedAt: Date } | undefined> {
|
||||
past: T[]
|
||||
present: T
|
||||
future: T[]
|
||||
@ -42,7 +42,7 @@ const initialState = {
|
||||
future: [],
|
||||
}
|
||||
|
||||
const reducer = <T extends { updatedAt: string } | undefined>(
|
||||
const reducer = <T extends { updatedAt: Date } | undefined>(
|
||||
state: State<T>,
|
||||
action: Action<T>
|
||||
) => {
|
||||
@ -112,7 +112,7 @@ const reducer = <T extends { updatedAt: string } | undefined>(
|
||||
}
|
||||
}
|
||||
|
||||
const useUndo = <T extends { updatedAt: string } | undefined>(
|
||||
const useUndo = <T extends { updatedAt: Date } | undefined>(
|
||||
initialPresent: T
|
||||
): [State<T>, Actions<T>] => {
|
||||
const [state, dispatch] = useReducer(reducer, {
|
||||
|
@ -26,6 +26,7 @@ export const parsePublicTypebotToTypebot = (
|
||||
workspaceId: existingTypebot.workspaceId,
|
||||
isArchived: existingTypebot.isArchived,
|
||||
isClosed: existingTypebot.isClosed,
|
||||
resultsTablePreferences: existingTypebot.resultsTablePreferences,
|
||||
})
|
||||
|
||||
export const parseTypebotToPublicTypebot = (
|
||||
@ -38,8 +39,8 @@ export const parseTypebotToPublicTypebot = (
|
||||
settings: typebot.settings,
|
||||
theme: typebot.theme,
|
||||
variables: typebot.variables,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
|
||||
export const checkIfTypebotsAreEqual = (typebotA: Typebot, typebotB: Typebot) =>
|
||||
|
@ -57,7 +57,7 @@ export const ResultsTableContainer = () => {
|
||||
|
||||
{typebot && (
|
||||
<SubmissionsTable
|
||||
preferences={typebot.resultsTablePreferences}
|
||||
preferences={typebot.resultsTablePreferences ?? undefined}
|
||||
resultHeader={resultHeader}
|
||||
data={tableData}
|
||||
onScrollToBottom={fetchNextPage}
|
||||
|
@ -94,11 +94,11 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
data:
|
||||
'groups' in data
|
||||
? data
|
||||
: (parseNewTypebot({
|
||||
: parseNewTypebot({
|
||||
ownerAvatarUrl: user.image,
|
||||
isBrandingEnabled: workspace.plan === Plan.FREE,
|
||||
...data,
|
||||
}) as Prisma.TypebotUncheckedCreateInput),
|
||||
}),
|
||||
})
|
||||
return res.send(typebot)
|
||||
}
|
||||
|
@ -1,8 +1,15 @@
|
||||
import { createContext } from '@/utils/server/context'
|
||||
import { appRouter } from '@/utils/server/routers/v1/_app'
|
||||
import { captureException } from '@sentry/nextjs'
|
||||
import { createOpenApiNextHandler } from 'trpc-openapi'
|
||||
|
||||
export default createOpenApiNextHandler({
|
||||
router: appRouter,
|
||||
createContext,
|
||||
onError({ error }) {
|
||||
if (error.code === 'INTERNAL_SERVER_ERROR') {
|
||||
captureException(error)
|
||||
console.error('Something went wrong', error)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
@ -10,6 +10,6 @@ const openApiDocument = generateOpenApiDocument(appRouter, {
|
||||
})
|
||||
|
||||
writeFileSync(
|
||||
'./openapi/builder.json',
|
||||
'./openapi/builder/_spec_.json',
|
||||
JSON.stringify(openApiDocument, null, 2)
|
||||
)
|
||||
|
@ -88,8 +88,8 @@ module.exports = {
|
||||
},
|
||||
presets: [
|
||||
[
|
||||
'docusaurus-preset-openapi',
|
||||
/** @type {import('docusaurus-preset-openapi').Options} */
|
||||
'@typebot.io/docusaurus-preset-openapi',
|
||||
/** @type {import('@typebot.io/docusaurus-preset-openapi').Options} */
|
||||
{
|
||||
api: {
|
||||
path: 'openapi',
|
||||
|
4
apps/docs/openapi/builder/_category_.json
Normal file
4
apps/docs/openapi/builder/_category_.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"label": "Builder",
|
||||
"sidebar_position": 2
|
||||
}
|
@ -1,6 +1,5 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
slug: /
|
||||
---
|
||||
|
||||
# Authentication
|
3
apps/docs/openapi/chat/_category_.json
Normal file
3
apps/docs/openapi/chat/_category_.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"label": "Chat (Experimental 🧪)"
|
||||
}
|
1696
apps/docs/openapi/chat/_spec_.json
Normal file
1696
apps/docs/openapi/chat/_spec_.json
Normal file
File diff suppressed because it is too large
Load Diff
28
apps/docs/openapi/introduction.md
Normal file
28
apps/docs/openapi/introduction.md
Normal file
@ -0,0 +1,28 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
slug: /
|
||||
---
|
||||
|
||||
# Introduction
|
||||
|
||||
Typebot currently offers 2 APIs: **Builder** and **Chat**
|
||||
|
||||
## Builder
|
||||
|
||||
The Builder API is about what you can edit on https://app.typebot.io (i.e. create typebots, insert blocks etc, get results...). It is currently under active development and new endpoints will be added incrementally.
|
||||
|
||||
## Chat
|
||||
|
||||
:::caution
|
||||
You should not use it in production. This API is experimental at the moment and will be heavily modified with time.
|
||||
:::
|
||||
|
||||
The Chat API allows you to execute (chat) with a typebot.
|
||||
|
||||
### How to find my `typebotId`
|
||||
|
||||
<img
|
||||
src="/img/api/typebotId.png"
|
||||
width="900"
|
||||
alt="Get typebot ID"
|
||||
/>
|
@ -13,21 +13,21 @@
|
||||
"write-translations": "docusaurus write-translations",
|
||||
"write-heading-ids": "docusaurus write-heading-ids",
|
||||
"update-search": "docker run -it --rm --env-file=.env -e \"CONFIG=$(cat docsearch-scrapper-config.json | jq -r tostring)\" algolia/docsearch-scraper",
|
||||
"api:generate": "tsx --tsconfig ../builder/tsconfig.json ../builder/src/utils/server/generateOpenApi.ts"
|
||||
"api:generate": "tsx --tsconfig ../builder/tsconfig.json ../builder/src/utils/server/generateOpenApi.ts && tsx --tsconfig ../viewer/openapi.tsconfig.json ../viewer/src/utils/server/generateOpenApi.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@docusaurus/core": "2.2.0",
|
||||
"@docusaurus/preset-classic": "2.2.0",
|
||||
"@docusaurus/theme-search-algolia": "2.2.0",
|
||||
"@docusaurus/theme-common": "2.2.0",
|
||||
"docusaurus-preset-openapi": "^0.6.3",
|
||||
"react": "17.0.2",
|
||||
"react-dom": "17.0.2",
|
||||
"@docusaurus/theme-search-algolia": "2.2.0",
|
||||
"@mdx-js/react": "1.6.22",
|
||||
"@svgr/webpack": "6.5.1",
|
||||
"clsx": "1.2.1",
|
||||
"@typebot.io/docusaurus-preset-openapi": "0.6.5",
|
||||
"file-loader": "6.2.0",
|
||||
"prism-react-renderer": "1.3.5",
|
||||
"react": "17.0.2",
|
||||
"react-dom": "17.0.2",
|
||||
"url-loader": "4.1.1"
|
||||
},
|
||||
"browserslist": {
|
||||
|
@ -110,3 +110,22 @@ details {
|
||||
.theme-api-markdown table td {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.admonition {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.admonition-heading svg {
|
||||
margin-right: 0.5rem;
|
||||
fill: currentColor;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.admonition-heading {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.admonition-heading h5 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
BIN
apps/docs/static/img/api/typebotId.png
vendored
Normal file
BIN
apps/docs/static/img/api/typebotId.png
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.7 MiB |
@ -1,4 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
const { withSentryConfig } = require('@sentry/nextjs')
|
||||
const path = require('path')
|
||||
const withTM = require('next-transpile-modules')([
|
||||
|
11
apps/viewer/openapi.tsconfig.json
Normal file
11
apps/viewer/openapi.tsconfig.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "tsconfig/nextjs.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"]
|
||||
}
|
@ -14,7 +14,7 @@
|
||||
"dependencies": {
|
||||
"@sentry/nextjs": "7.21.1",
|
||||
"aws-sdk": "2.1261.0",
|
||||
"bot-engine": "*",
|
||||
"bot-engine": "workspace:*",
|
||||
"cors": "2.8.5",
|
||||
"cuid": "2.1.8",
|
||||
"db": "workspace:*",
|
||||
@ -26,7 +26,9 @@
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"sanitize-html": "2.7.3",
|
||||
"stripe": "11.1.0"
|
||||
"stripe": "11.1.0",
|
||||
"trpc-openapi": "1.0.0-alpha.4",
|
||||
"@trpc/server": "10.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/preset-env": "7.20.2",
|
||||
@ -51,6 +53,8 @@
|
||||
"papaparse": "5.3.2",
|
||||
"tsconfig": "workspace:*",
|
||||
"typescript": "4.9.3",
|
||||
"zod": "3.19.1",
|
||||
"superjson": "^1.11.0",
|
||||
"utils": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1 @@
|
||||
export * from './utils'
|
@ -0,0 +1 @@
|
||||
export * from './validateButtonInput'
|
@ -0,0 +1,6 @@
|
||||
import { ChoiceInputBlock } from 'models'
|
||||
|
||||
export const validateButtonInput = (
|
||||
buttonBlock: ChoiceInputBlock,
|
||||
input: string
|
||||
) => buttonBlock.items.some((item) => item.content === input)
|
1
apps/viewer/src/features/blocks/inputs/date/api/index.ts
Normal file
1
apps/viewer/src/features/blocks/inputs/date/api/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './utils'
|
@ -0,0 +1 @@
|
||||
export * from './parseReadableDate'
|
@ -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}` : ''}`
|
||||
}
|
@ -0,0 +1 @@
|
||||
export * from './utils'
|
@ -0,0 +1 @@
|
||||
export * from './validateEmail'
|
@ -0,0 +1,4 @@
|
||||
const emailRegex =
|
||||
/^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
|
||||
|
||||
export const validateEmail = (email: string) => emailRegex.test(email)
|
@ -0,0 +1 @@
|
||||
export { validatePhoneNumber } from './utils/validatePhoneNumber'
|
@ -0,0 +1,4 @@
|
||||
const phoneRegex = /^\+?[0-9]{6,}$/
|
||||
|
||||
export const validatePhoneNumber = (phoneNumber: string) =>
|
||||
phoneRegex.test(phoneNumber)
|
1
apps/viewer/src/features/blocks/inputs/url/api/index.ts
Normal file
1
apps/viewer/src/features/blocks/inputs/url/api/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { validateUrl } from './utils/validateUrl'
|
@ -0,0 +1,4 @@
|
||||
const urlRegex =
|
||||
/^(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})$/
|
||||
|
||||
export const validateUrl = (url: string) => urlRegex.test(url)
|
@ -0,0 +1 @@
|
||||
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 @@
|
||||
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,
|
||||
}
|
||||
}
|
@ -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'
|
@ -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'
|
1
apps/viewer/src/features/blocks/logic/code/api/index.ts
Normal file
1
apps/viewer/src/features/blocks/logic/code/api/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { executeCode } from './utils/executeCode'
|
@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { executeCondition } from './utils/executeCondition'
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { executeRedirect } from './utils/executeRedirect'
|
@ -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,
|
||||
}
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { executeSetVariable } from './utils/executeSetVariable'
|
@ -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
|
||||
}
|
||||
}
|
@ -0,0 +1 @@
|
||||
export * from './utils'
|
@ -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
|
||||
}
|
@ -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 = (
|
@ -0,0 +1,2 @@
|
||||
export * from './executeTypebotLink'
|
||||
export * from './getLinkedTypebots'
|
6
apps/viewer/src/features/chat/api/chatRouter.ts
Normal file
6
apps/viewer/src/features/chat/api/chatRouter.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { router } from '@/utils/server/trpc'
|
||||
import { sendMessageProcedure } from './procedures'
|
||||
|
||||
export const chatRouter = router({
|
||||
sendMessage: sendMessageProcedure,
|
||||
})
|
2
apps/viewer/src/features/chat/api/index.ts
Normal file
2
apps/viewer/src/features/chat/api/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './chatRouter'
|
||||
export { getSession } from './utils'
|
1
apps/viewer/src/features/chat/api/procedures/index.ts
Normal file
1
apps/viewer/src/features/chat/api/procedures/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './sendMessageProcedure'
|
@ -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,
|
||||
}
|
||||
}
|
150
apps/viewer/src/features/chat/api/utils/continueBotFlow.ts
Normal file
150
apps/viewer/src/features/chat/api/utils/continueBotFlow.ts
Normal 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
|
||||
}
|
113
apps/viewer/src/features/chat/api/utils/executeGroup.ts
Normal file
113
apps/viewer/src/features/chat/api/utils/executeGroup.ts
Normal 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 }
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
24
apps/viewer/src/features/chat/api/utils/executeLogic.ts
Normal file
24
apps/viewer/src/features/chat/api/utils/executeLogic.ts
Normal 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)
|
||||
}
|
||||
}
|
38
apps/viewer/src/features/chat/api/utils/getNextGroup.ts
Normal file
38
apps/viewer/src/features/chat/api/utils/getNextGroup.ts
Normal 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) },
|
||||
}
|
||||
}
|
12
apps/viewer/src/features/chat/api/utils/getSessionState.ts
Normal file
12
apps/viewer/src/features/chat/api/utils/getSessionState.ts
Normal 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
|
||||
}
|
5
apps/viewer/src/features/chat/api/utils/index.ts
Normal file
5
apps/viewer/src/features/chat/api/utils/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export * from './continueBotFlow'
|
||||
export * from './executeGroup'
|
||||
export * from './getNextGroup'
|
||||
export * from './getSessionState'
|
||||
export * from './startBotFlow'
|
13
apps/viewer/src/features/chat/api/utils/startBotFlow.ts
Normal file
13
apps/viewer/src/features/chat/api/utils/startBotFlow.ts
Normal 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)
|
||||
}
|
145
apps/viewer/src/features/chat/chat.spec.ts
Normal file
145
apps/viewer/src/features/chat/chat.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
1
apps/viewer/src/features/chat/index.ts
Normal file
1
apps/viewer/src/features/chat/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './types'
|
13
apps/viewer/src/features/chat/types.ts
Normal file
13
apps/viewer/src/features/chat/types.ts
Normal 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'>
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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 {
|
||||
|
1
apps/viewer/src/features/results/api/index.ts
Normal file
1
apps/viewer/src/features/results/api/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './utils'
|
@ -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
|
1
apps/viewer/src/features/results/api/utils/index.ts
Normal file
1
apps/viewer/src/features/results/api/utils/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './getResultValues'
|
@ -1 +0,0 @@
|
||||
export * from './getLinkedTypebots'
|
1
apps/viewer/src/features/variables/index.ts
Normal file
1
apps/viewer/src/features/variables/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './utils'
|
163
apps/viewer/src/features/variables/utils.ts
Normal file
163
apps/viewer/src/features/variables/utils.ts
Normal 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,
|
||||
]
|
||||
}
|
@ -1 +0,0 @@
|
||||
export * from './parseSampleResult'
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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'
|
||||
|
@ -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)
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user