⚗️ 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 { withSentryConfig } = require('@sentry/nextjs')
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
const withTM = require('next-transpile-modules')([
|
const withTM = require('next-transpile-modules')([
|
||||||
|
@ -19,7 +19,6 @@ export const getAuthenticatedUser = async (
|
|||||||
const authenticateByToken = async (
|
const authenticateByToken = async (
|
||||||
apiToken: string
|
apiToken: string
|
||||||
): Promise<User | undefined> => {
|
): Promise<User | undefined> => {
|
||||||
console.log(window)
|
|
||||||
if (typeof window !== 'undefined') return
|
if (typeof window !== 'undefined') return
|
||||||
return (await prisma.user.findFirst({
|
return (await prisma.user.findFirst({
|
||||||
where: { apiTokens: { some: { token: apiToken } } },
|
where: { apiTokens: { some: { token: apiToken } } },
|
||||||
|
@ -30,6 +30,7 @@ export const parseNewTypebot = ({
|
|||||||
| 'icon'
|
| 'icon'
|
||||||
| 'isArchived'
|
| 'isArchived'
|
||||||
| 'isClosed'
|
| 'isClosed'
|
||||||
|
| 'resultsTablePreferences'
|
||||||
> => {
|
> => {
|
||||||
const startGroupId = cuid()
|
const startGroupId = cuid()
|
||||||
const startBlockId = cuid()
|
const startBlockId = cuid()
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { duplicateWebhookQueries } from '@/features/blocks/integrations/webhook'
|
import { duplicateWebhookQueries } from '@/features/blocks/integrations/webhook'
|
||||||
import cuid from 'cuid'
|
import cuid from 'cuid'
|
||||||
import { Plan } from 'db'
|
import { Plan, Prisma } from 'db'
|
||||||
import {
|
import {
|
||||||
ChoiceInputBlock,
|
ChoiceInputBlock,
|
||||||
ConditionBlock,
|
ConditionBlock,
|
||||||
@ -38,7 +38,10 @@ export const importTypebotQuery = async (typebot: Typebot, userPlan: Plan) => {
|
|||||||
const duplicateTypebot = (
|
const duplicateTypebot = (
|
||||||
typebot: Typebot,
|
typebot: Typebot,
|
||||||
userPlan: Plan
|
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 groupIdsMapping = generateOldNewIdsMapping(typebot.groups)
|
||||||
const edgeIdsMapping = generateOldNewIdsMapping(typebot.edges)
|
const edgeIdsMapping = generateOldNewIdsMapping(typebot.edges)
|
||||||
const webhookIdsMapping = generateOldNewIdsMapping(
|
const webhookIdsMapping = generateOldNewIdsMapping(
|
||||||
@ -119,8 +122,8 @@ const duplicateTypebot = (
|
|||||||
general: { ...typebot.settings.general, isBrandingEnabled: true },
|
general: { ...typebot.settings.general, isBrandingEnabled: true },
|
||||||
}
|
}
|
||||||
: typebot.settings,
|
: typebot.settings,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date(),
|
||||||
resultsTablePreferences: typebot.resultsTablePreferences ?? undefined,
|
resultsTablePreferences: typebot.resultsTablePreferences ?? undefined,
|
||||||
},
|
},
|
||||||
webhookIdsMapping,
|
webhookIdsMapping,
|
||||||
|
@ -11,7 +11,7 @@ enum ActionType {
|
|||||||
Flush = 'FLUSH',
|
Flush = 'FLUSH',
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Actions<T extends { updatedAt: string } | undefined> {
|
export interface Actions<T extends { updatedAt: Date } | undefined> {
|
||||||
set: (
|
set: (
|
||||||
newPresent: T | ((current: T) => T),
|
newPresent: T | ((current: T) => T),
|
||||||
options?: { updateDate: boolean }
|
options?: { updateDate: boolean }
|
||||||
@ -24,13 +24,13 @@ export interface Actions<T extends { updatedAt: string } | undefined> {
|
|||||||
presentRef: React.MutableRefObject<T>
|
presentRef: React.MutableRefObject<T>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Action<T extends { updatedAt: string } | undefined> {
|
interface Action<T extends { updatedAt: Date } | undefined> {
|
||||||
type: ActionType
|
type: ActionType
|
||||||
newPresent?: T
|
newPresent?: T
|
||||||
updateDate?: boolean
|
updateDate?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface State<T extends { updatedAt: string } | undefined> {
|
export interface State<T extends { updatedAt: Date } | undefined> {
|
||||||
past: T[]
|
past: T[]
|
||||||
present: T
|
present: T
|
||||||
future: T[]
|
future: T[]
|
||||||
@ -42,7 +42,7 @@ const initialState = {
|
|||||||
future: [],
|
future: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
const reducer = <T extends { updatedAt: string } | undefined>(
|
const reducer = <T extends { updatedAt: Date } | undefined>(
|
||||||
state: State<T>,
|
state: State<T>,
|
||||||
action: Action<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
|
initialPresent: T
|
||||||
): [State<T>, Actions<T>] => {
|
): [State<T>, Actions<T>] => {
|
||||||
const [state, dispatch] = useReducer(reducer, {
|
const [state, dispatch] = useReducer(reducer, {
|
||||||
|
@ -26,6 +26,7 @@ export const parsePublicTypebotToTypebot = (
|
|||||||
workspaceId: existingTypebot.workspaceId,
|
workspaceId: existingTypebot.workspaceId,
|
||||||
isArchived: existingTypebot.isArchived,
|
isArchived: existingTypebot.isArchived,
|
||||||
isClosed: existingTypebot.isClosed,
|
isClosed: existingTypebot.isClosed,
|
||||||
|
resultsTablePreferences: existingTypebot.resultsTablePreferences,
|
||||||
})
|
})
|
||||||
|
|
||||||
export const parseTypebotToPublicTypebot = (
|
export const parseTypebotToPublicTypebot = (
|
||||||
@ -38,8 +39,8 @@ export const parseTypebotToPublicTypebot = (
|
|||||||
settings: typebot.settings,
|
settings: typebot.settings,
|
||||||
theme: typebot.theme,
|
theme: typebot.theme,
|
||||||
variables: typebot.variables,
|
variables: typebot.variables,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const checkIfTypebotsAreEqual = (typebotA: Typebot, typebotB: Typebot) =>
|
export const checkIfTypebotsAreEqual = (typebotA: Typebot, typebotB: Typebot) =>
|
||||||
|
@ -57,7 +57,7 @@ export const ResultsTableContainer = () => {
|
|||||||
|
|
||||||
{typebot && (
|
{typebot && (
|
||||||
<SubmissionsTable
|
<SubmissionsTable
|
||||||
preferences={typebot.resultsTablePreferences}
|
preferences={typebot.resultsTablePreferences ?? undefined}
|
||||||
resultHeader={resultHeader}
|
resultHeader={resultHeader}
|
||||||
data={tableData}
|
data={tableData}
|
||||||
onScrollToBottom={fetchNextPage}
|
onScrollToBottom={fetchNextPage}
|
||||||
|
@ -94,11 +94,11 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
|||||||
data:
|
data:
|
||||||
'groups' in data
|
'groups' in data
|
||||||
? data
|
? data
|
||||||
: (parseNewTypebot({
|
: parseNewTypebot({
|
||||||
ownerAvatarUrl: user.image,
|
ownerAvatarUrl: user.image,
|
||||||
isBrandingEnabled: workspace.plan === Plan.FREE,
|
isBrandingEnabled: workspace.plan === Plan.FREE,
|
||||||
...data,
|
...data,
|
||||||
}) as Prisma.TypebotUncheckedCreateInput),
|
}),
|
||||||
})
|
})
|
||||||
return res.send(typebot)
|
return res.send(typebot)
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,15 @@
|
|||||||
import { createContext } from '@/utils/server/context'
|
import { createContext } from '@/utils/server/context'
|
||||||
import { appRouter } from '@/utils/server/routers/v1/_app'
|
import { appRouter } from '@/utils/server/routers/v1/_app'
|
||||||
|
import { captureException } from '@sentry/nextjs'
|
||||||
import { createOpenApiNextHandler } from 'trpc-openapi'
|
import { createOpenApiNextHandler } from 'trpc-openapi'
|
||||||
|
|
||||||
export default createOpenApiNextHandler({
|
export default createOpenApiNextHandler({
|
||||||
router: appRouter,
|
router: appRouter,
|
||||||
createContext,
|
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(
|
writeFileSync(
|
||||||
'./openapi/builder.json',
|
'./openapi/builder/_spec_.json',
|
||||||
JSON.stringify(openApiDocument, null, 2)
|
JSON.stringify(openApiDocument, null, 2)
|
||||||
)
|
)
|
||||||
|
@ -88,8 +88,8 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
presets: [
|
presets: [
|
||||||
[
|
[
|
||||||
'docusaurus-preset-openapi',
|
'@typebot.io/docusaurus-preset-openapi',
|
||||||
/** @type {import('docusaurus-preset-openapi').Options} */
|
/** @type {import('@typebot.io/docusaurus-preset-openapi').Options} */
|
||||||
{
|
{
|
||||||
api: {
|
api: {
|
||||||
path: 'openapi',
|
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
|
sidebar_position: 1
|
||||||
slug: /
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Authentication
|
# 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-translations": "docusaurus write-translations",
|
||||||
"write-heading-ids": "docusaurus write-heading-ids",
|
"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",
|
"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": {
|
"dependencies": {
|
||||||
"@docusaurus/core": "2.2.0",
|
"@docusaurus/core": "2.2.0",
|
||||||
"@docusaurus/preset-classic": "2.2.0",
|
"@docusaurus/preset-classic": "2.2.0",
|
||||||
"@docusaurus/theme-search-algolia": "2.2.0",
|
|
||||||
"@docusaurus/theme-common": "2.2.0",
|
"@docusaurus/theme-common": "2.2.0",
|
||||||
"docusaurus-preset-openapi": "^0.6.3",
|
"@docusaurus/theme-search-algolia": "2.2.0",
|
||||||
"react": "17.0.2",
|
|
||||||
"react-dom": "17.0.2",
|
|
||||||
"@mdx-js/react": "1.6.22",
|
"@mdx-js/react": "1.6.22",
|
||||||
"@svgr/webpack": "6.5.1",
|
"@svgr/webpack": "6.5.1",
|
||||||
"clsx": "1.2.1",
|
"clsx": "1.2.1",
|
||||||
|
"@typebot.io/docusaurus-preset-openapi": "0.6.5",
|
||||||
"file-loader": "6.2.0",
|
"file-loader": "6.2.0",
|
||||||
"prism-react-renderer": "1.3.5",
|
"prism-react-renderer": "1.3.5",
|
||||||
|
"react": "17.0.2",
|
||||||
|
"react-dom": "17.0.2",
|
||||||
"url-loader": "4.1.1"
|
"url-loader": "4.1.1"
|
||||||
},
|
},
|
||||||
"browserslist": {
|
"browserslist": {
|
||||||
|
@ -110,3 +110,22 @@ details {
|
|||||||
.theme-api-markdown table td {
|
.theme-api-markdown table td {
|
||||||
border: 0;
|
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 { withSentryConfig } = require('@sentry/nextjs')
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
const withTM = require('next-transpile-modules')([
|
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": {
|
"dependencies": {
|
||||||
"@sentry/nextjs": "7.21.1",
|
"@sentry/nextjs": "7.21.1",
|
||||||
"aws-sdk": "2.1261.0",
|
"aws-sdk": "2.1261.0",
|
||||||
"bot-engine": "*",
|
"bot-engine": "workspace:*",
|
||||||
"cors": "2.8.5",
|
"cors": "2.8.5",
|
||||||
"cuid": "2.1.8",
|
"cuid": "2.1.8",
|
||||||
"db": "workspace:*",
|
"db": "workspace:*",
|
||||||
@ -26,7 +26,9 @@
|
|||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"sanitize-html": "2.7.3",
|
"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": {
|
"devDependencies": {
|
||||||
"@babel/preset-env": "7.20.2",
|
"@babel/preset-env": "7.20.2",
|
||||||
@ -51,6 +53,8 @@
|
|||||||
"papaparse": "5.3.2",
|
"papaparse": "5.3.2",
|
||||||
"tsconfig": "workspace:*",
|
"tsconfig": "workspace:*",
|
||||||
"typescript": "4.9.3",
|
"typescript": "4.9.3",
|
||||||
|
"zod": "3.19.1",
|
||||||
|
"superjson": "^1.11.0",
|
||||||
"utils": "workspace:*"
|
"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 test, { expect } from '@playwright/test'
|
||||||
import { createSmtpCredentials } from '../../test/utils/databaseActions'
|
import { createSmtpCredentials } from '../../../../test/utils/databaseActions'
|
||||||
import cuid from 'cuid'
|
import cuid from 'cuid'
|
||||||
import { SmtpCredentialsData } from 'models'
|
import { SmtpCredentialsData } from 'models'
|
||||||
import { importTypebotInDatabase } from 'utils/playwright/databaseActions'
|
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'
|
import { isDefined } from 'utils'
|
||||||
|
|
||||||
export const getLinkedTypebots = async (
|
export const getLinkedTypebots = async (
|
||||||
typebot: Typebot | PublicTypebot,
|
typebot: Pick<PublicTypebot, 'groups'>,
|
||||||
user?: User
|
user?: User
|
||||||
): Promise<(Typebot | PublicTypebot)[]> => {
|
): Promise<(Typebot | PublicTypebot)[]> => {
|
||||||
const linkedTypebotIds = (
|
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'
|
import { saveLog } from './utils'
|
||||||
|
|
||||||
export const saveErrorLog = (
|
export const saveErrorLog = ({
|
||||||
resultId: string | undefined,
|
resultId,
|
||||||
message: string,
|
message,
|
||||||
|
details,
|
||||||
|
}: {
|
||||||
|
resultId: string | undefined
|
||||||
|
message: string
|
||||||
details?: unknown
|
details?: unknown
|
||||||
) => saveLog('error', resultId, message, details)
|
}) => saveLog('error', resultId, message, details)
|
||||||
|
@ -1,7 +1,11 @@
|
|||||||
import { saveLog } from './utils'
|
import { saveLog } from './utils'
|
||||||
|
|
||||||
export const saveSuccessLog = (
|
export const saveSuccessLog = ({
|
||||||
resultId: string | undefined,
|
resultId,
|
||||||
message: string,
|
message,
|
||||||
|
details,
|
||||||
|
}: {
|
||||||
|
resultId: string | undefined
|
||||||
|
message: string
|
||||||
details?: unknown
|
details?: unknown
|
||||||
) => saveLog('success', resultId, message, details)
|
}) => saveLog('success', resultId, message, details)
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import prisma from '@/lib/prisma'
|
import prisma from '@/lib/prisma'
|
||||||
|
import { isNotDefined } from 'utils'
|
||||||
|
|
||||||
export const saveLog = (
|
export const saveLog = (
|
||||||
status: 'error' | 'success',
|
status: 'error' | 'success',
|
||||||
@ -12,12 +13,13 @@ export const saveLog = (
|
|||||||
resultId,
|
resultId,
|
||||||
status,
|
status,
|
||||||
description: message,
|
description: message,
|
||||||
details: formatDetails(details) as string,
|
details: formatDetails(details) as string | null,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatDetails = (details: unknown) => {
|
const formatDetails = (details: unknown) => {
|
||||||
|
if (isNotDefined(details)) return null
|
||||||
try {
|
try {
|
||||||
return JSON.stringify(details, null, 2).substring(0, 1000)
|
return JSON.stringify(details, null, 2).substring(0, 1000)
|
||||||
} catch {
|
} 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
|
(row) => row[referenceCell.column as string] === referenceCell.value
|
||||||
)
|
)
|
||||||
if (!row) {
|
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" })
|
return res.status(404).send({ message: "Couldn't find row" })
|
||||||
}
|
}
|
||||||
const response = {
|
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)
|
return res.send(response)
|
||||||
} catch (err) {
|
} 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)
|
return res.status(500).send(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -74,10 +84,14 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
|||||||
await doc.loadInfo()
|
await doc.loadInfo()
|
||||||
const sheet = doc.sheetsById[sheetId]
|
const sheet = doc.sheetsById[sheetId]
|
||||||
await sheet.addRow(values)
|
await sheet.addRow(values)
|
||||||
await saveSuccessLog(resultId, 'Succesfully inserted row')
|
await saveSuccessLog({ resultId, message: 'Succesfully inserted row' })
|
||||||
return res.send({ message: 'Success' })
|
return res.send({ message: 'Success' })
|
||||||
} catch (err) {
|
} 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)
|
return res.status(500).send(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -110,10 +124,14 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
|||||||
rows[updatingRowIndex][key] = values[key]
|
rows[updatingRowIndex][key] = values[key]
|
||||||
}
|
}
|
||||||
await rows[updatingRowIndex].save()
|
await rows[updatingRowIndex].save()
|
||||||
await saveSuccessLog(resultId, 'Succesfully updated row')
|
await saveSuccessLog({ resultId, message: 'Succesfully updated row' })
|
||||||
return res.send({ message: 'Success' })
|
return res.send({ message: 'Success' })
|
||||||
} catch (err) {
|
} 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)
|
return res.status(500).send(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -20,9 +20,9 @@ import { stringify } from 'qs'
|
|||||||
import { withSentry } from '@sentry/nextjs'
|
import { withSentry } from '@sentry/nextjs'
|
||||||
import Cors from 'cors'
|
import Cors from 'cors'
|
||||||
import prisma from '@/lib/prisma'
|
import prisma from '@/lib/prisma'
|
||||||
import { getLinkedTypebots } from '@/features/typebotLink/api'
|
|
||||||
import { saveErrorLog, saveSuccessLog } from '@/features/logs/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())
|
const cors = initMiddleware(Cors())
|
||||||
|
|
||||||
@ -149,10 +149,14 @@ export const executeWebhook =
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const response = await got(request.url, omit(request, 'url'))
|
const response = await got(request.url, omit(request, 'url'))
|
||||||
await saveSuccessLog(resultId, 'Webhook successfuly executed.', {
|
await saveSuccessLog({
|
||||||
statusCode: response.statusCode,
|
resultId,
|
||||||
request,
|
message: 'Webhook successfuly executed.',
|
||||||
response: safeJsonParse(response.body).data,
|
details: {
|
||||||
|
statusCode: response.statusCode,
|
||||||
|
request,
|
||||||
|
response: safeJsonParse(response.body).data,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
statusCode: response.statusCode,
|
statusCode: response.statusCode,
|
||||||
@ -164,9 +168,13 @@ export const executeWebhook =
|
|||||||
statusCode: error.response.statusCode,
|
statusCode: error.response.statusCode,
|
||||||
data: safeJsonParse(error.response.body as string).data,
|
data: safeJsonParse(error.response.body as string).data,
|
||||||
}
|
}
|
||||||
await saveErrorLog(resultId, 'Webhook returned an error', {
|
await saveErrorLog({
|
||||||
request,
|
resultId,
|
||||||
response,
|
message: 'Webhook returned an error',
|
||||||
|
details: {
|
||||||
|
request,
|
||||||
|
response,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
@ -175,9 +183,13 @@ export const executeWebhook =
|
|||||||
data: { message: `Error from Typebot server: ${error}` },
|
data: { message: `Error from Typebot server: ${error}` },
|
||||||
}
|
}
|
||||||
console.error(error)
|
console.error(error)
|
||||||
await saveErrorLog(resultId, 'Webhook failed to execute', {
|
await saveErrorLog({
|
||||||
request,
|
resultId,
|
||||||
response,
|
message: 'Webhook failed to execute',
|
||||||
|
details: {
|
||||||
|
request,
|
||||||
|
response,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { authenticateUser } from '@/features/auth/api'
|
import { authenticateUser } from '@/features/auth/api'
|
||||||
import { getLinkedTypebots } from '@/features/typebotLink/api'
|
import { getLinkedTypebots } from '@/features/blocks/logic/typebotLink/api'
|
||||||
import { parseSampleResult } from '@/features/webhook/api'
|
import { parseSampleResult } from '@/features/blocks/integrations/webhook/api'
|
||||||
import prisma from '@/lib/prisma'
|
import prisma from '@/lib/prisma'
|
||||||
import { Typebot } from 'models'
|
import { Typebot } from 'models'
|
||||||
import { NextApiRequest, NextApiResponse } from 'next'
|
import { NextApiRequest, NextApiResponse } from 'next'
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
import { authenticateUser } from '@/features/auth/api'
|
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 prisma from '@/lib/prisma'
|
||||||
import { Typebot } from 'models'
|
import { Typebot } from 'models'
|
||||||
import { NextApiRequest, NextApiResponse } from 'next'
|
import { NextApiRequest, NextApiResponse } from 'next'
|
||||||
import { methodNotAllowed } from 'utils/api'
|
import { methodNotAllowed } from 'utils/api'
|
||||||
import { parseSampleResult } from '@/features/webhook/api'
|
|
||||||
|
|
||||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
const user = await authenticateUser(req)
|
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