2
0

⚗️ Implement bot v2 MVP (#194)

Closes #190
This commit is contained in:
Baptiste Arnaud
2022-12-22 17:02:34 +01:00
committed by GitHub
parent e55823e011
commit 1a3869ae6d
202 changed files with 8060 additions and 1152 deletions

View File

@ -1,58 +1,40 @@
import { checkChatsUsage } from '@/features/usage'
import { parsePrefilledVariables } from '@/features/variables'
import prisma from '@/lib/prisma'
import { publicProcedure } from '@/utils/server/trpc'
import { TRPCError } from '@trpc/server'
import { Prisma } from 'db'
import {
chatReplySchema,
ChatSession,
PublicTypebotWithName,
PublicTypebot,
Result,
sendMessageInputSchema,
SessionState,
typebotSchema,
StartParams,
Typebot,
Variable,
} from 'models'
import { z } from 'zod'
import { continueBotFlow, getSession, startBotFlow } from '../utils'
export const sendMessageProcedure = publicProcedure
.meta({
openapi: {
method: 'POST',
path: '/typebots/{typebotId}/sendMessage',
path: '/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.",
'To initiate a chat, do not provide a `sessionId` nor a `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 } }) => {
.input(sendMessageInputSchema)
.output(chatReplySchema)
.query(async ({ input: { sessionId, message, startParams } }) => {
const session = sessionId ? await getSession(sessionId) : null
if (!session) {
const { sessionId, typebot, messages, input } = await startSession(
typebotId
)
const { sessionId, typebot, messages, input, resultId } =
await startSession(startParams)
return {
sessionId,
typebot: typebot
@ -60,14 +42,14 @@ export const sendMessageProcedure = publicProcedure
theme: typebot.theme,
settings: typebot.settings,
}
: null,
: undefined,
messages,
input,
resultId,
}
} else {
const { messages, input, logic, newSessionState } = await continueBotFlow(
session.state
)(message)
const { messages, input, logic, newSessionState, integrations } =
await continueBotFlow(session.state)(message)
await prisma.chatSession.updateMany({
where: { id: session.id },
@ -80,59 +62,103 @@ export const sendMessageProcedure = publicProcedure
messages,
input,
logic,
integrations,
}
}
})
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,
},
})
const startSession = async (startParams?: StartParams) => {
if (!startParams?.typebotId)
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'No typebotId provided in startParams',
})
const typebotQuery = startParams.isPreview
? await prisma.typebot.findUnique({
where: { id: startParams.typebotId },
select: {
groups: true,
edges: true,
settings: true,
theme: true,
variables: true,
isArchived: true,
},
})
: await prisma.typebot.findUnique({
where: { id: startParams.typebotId },
select: {
publishedTypebot: {
select: {
groups: true,
edges: true,
settings: true,
theme: true,
variables: true,
},
},
name: true,
isClosed: true,
isArchived: true,
id: true,
},
})
if (!typebot?.publishedTypebot || typebot.isArchived)
const typebot =
typebotQuery && 'publishedTypebot' in typebotQuery
? (typebotQuery.publishedTypebot as Pick<
PublicTypebot,
'groups' | 'edges' | 'settings' | 'theme' | 'variables'
>)
: (typebotQuery as Pick<
Typebot,
'groups' | 'edges' | 'settings' | 'theme' | 'variables' | 'isArchived'
>)
if (!typebot)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Typebot not found',
})
if (typebot.isClosed)
if ('isClosed' in typebot && 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 hasReachedLimit = !startParams.isPreview
? await checkChatsUsage(startParams.typebotId)
: false
const publicTypebot = typebot.publishedTypebot as PublicTypebotWithName
if (hasReachedLimit)
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Your workspace reached its chat limit',
})
const startVariables = startParams.prefilledVariables
? parsePrefilledVariables(typebot.variables, startParams.prefilledVariables)
: typebot.variables
const result = await getResult({ ...startParams, startVariables })
const initialState: SessionState = {
typebot: {
id: publicTypebot.typebotId,
groups: publicTypebot.groups,
edges: publicTypebot.edges,
variables: publicTypebot.variables,
id: startParams.typebotId,
groups: typebot.groups,
edges: typebot.edges,
variables: startVariables,
},
linkedTypebots: {
typebots: [],
queue: [],
},
result: { id: result.id, variables: [], hasStarted: false },
result: result
? { id: result.id, variables: result.variables, hasStarted: false }
: undefined,
isPreview: false,
currentTypebotId: publicTypebot.typebotId,
currentTypebotId: startParams.typebotId,
}
const {
@ -145,8 +171,6 @@ const startSession = async (typebotId: string) => {
if (!input)
return {
messages,
typebot: null,
sessionId: null,
logic,
}
@ -165,13 +189,47 @@ const startSession = async (typebotId: string) => {
})) as ChatSession
return {
resultId: result?.id,
sessionId: session.id,
typebot: {
theme: publicTypebot.theme,
settings: publicTypebot.settings,
theme: typebot.theme,
settings: typebot.settings,
},
messages,
input,
logic,
}
}
const getResult = async ({
typebotId,
isPreview,
resultId,
startVariables,
}: Pick<StartParams, 'isPreview' | 'resultId' | 'typebotId'> & {
startVariables: Variable[]
}) => {
if (isPreview) return undefined
const data = {
isCompleted: false,
typebotId: typebotId,
variables: { set: startVariables.filter((variable) => variable.value) },
} satisfies Prisma.ResultUncheckedCreateInput
const select = {
id: true,
variables: true,
hasStarted: true,
} satisfies Prisma.ResultSelect
return (
resultId
? await prisma.result.update({
where: { id: resultId },
data,
select,
})
: await prisma.result.create({
data,
select,
})
) as Pick<Result, 'id' | 'variables' | 'hasStarted'>
}

View File

@ -1,11 +1,16 @@
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 {
formatPhoneNumber,
validatePhoneNumber,
} from '@/features/blocks/inputs/phone/api'
import { validateUrl } from '@/features/blocks/inputs/url/api'
import { parseVariables } from '@/features/variables'
import prisma from '@/lib/prisma'
import { TRPCError } from '@trpc/server'
import {
Block,
BlockType,
BubbleBlockType,
ChatReply,
InputBlock,
@ -20,7 +25,7 @@ import { getNextGroup } from './getNextGroup'
export const continueBotFlow =
(state: SessionState) =>
async (
reply: string
reply?: string
): Promise<ChatReply & { newSessionState?: SessionState }> => {
const group = state.typebot.groups.find(
(group) => group.id === state.currentBlock?.groupId
@ -30,7 +35,7 @@ export const continueBotFlow =
(block) => block.id === state.currentBlock?.blockId
) ?? -1
const block = blockIndex > 0 ? group?.blocks[blockIndex ?? 0] : null
const block = blockIndex >= 0 ? group?.blocks[blockIndex ?? 0] : null
if (!block || !group)
throw new TRPCError({
@ -44,9 +49,15 @@ export const continueBotFlow =
message: 'Current block is not an input block',
})
if (!isInputValid(reply, block)) return parseRetryMessage(block)
const formattedReply = formatReply(reply, block.type)
const newVariables = await processAndSaveAnswer(state, block)(reply)
if (!formattedReply || !isReplyValid(formattedReply, block))
return parseRetryMessage(block)
const newVariables = await processAndSaveAnswer(
state,
block
)(formattedReply)
const newSessionState = {
...state,
@ -58,15 +69,15 @@ export const continueBotFlow =
const groupHasMoreBlocks = blockIndex < group.blocks.length - 1
if (groupHasMoreBlocks) {
const nextEdgeId = getOutgoingEdgeId(newSessionState)(block, formattedReply)
if (groupHasMoreBlocks && !nextEdgeId) {
return executeGroup(newSessionState)({
...group,
blocks: group.blocks.slice(blockIndex + 1),
})
}
const nextEdgeId = block.outgoingEdgeId
if (!nextEdgeId && state.linkedTypebots.queue.length === 0)
return { messages: [] }
@ -80,7 +91,7 @@ export const continueBotFlow =
const processAndSaveAnswer =
(state: Pick<SessionState, 'result' | 'typebot'>, block: InputBlock) =>
async (reply: string): Promise<Variable[]> => {
await saveAnswer(state.result.id, block)(reply)
state.result && (await saveAnswer(state.result.id, block)(reply))
const newVariables = saveVariableValueIfAny(state, block)(reply)
return newVariables
}
@ -105,22 +116,26 @@ const saveVariableValueIfAny =
]
}
const parseRetryMessage = (block: InputBlock) => ({
messages: [
{
type: BubbleBlockType.TEXT,
content: {
plainText:
'retryMessageContent' in block.options
? block.options.retryMessageContent
: 'Invalid message. Please, try again.',
richText: [],
html: '',
const parseRetryMessage = (
block: InputBlock
): Pick<ChatReply, 'messages' | 'input'> => {
const retryMessage =
'retryMessageContent' in block.options && block.options.retryMessageContent
? block.options.retryMessageContent
: 'Invalid message. Please, try again.'
return {
messages: [
{
type: BubbleBlockType.TEXT,
content: {
plainText: retryMessage,
html: `<div>${retryMessage}</div>`,
},
},
},
],
input: block,
})
],
input: block,
}
}
const saveAnswer =
(resultId: string, block: InputBlock) => async (reply: string) => {
@ -135,7 +150,35 @@ const saveAnswer =
})
}
export const isInputValid = (inputValue: string, block: Block): boolean => {
const getOutgoingEdgeId =
({ typebot: { variables } }: Pick<SessionState, 'typebot'>) =>
(block: InputBlock, reply?: string) => {
if (
block.type === InputBlockType.CHOICE &&
!block.options.isMultipleChoice &&
reply
) {
const matchedItem = block.items.find(
(item) => parseVariables(variables)(item.content) === reply
)
if (matchedItem?.outgoingEdgeId) return matchedItem.outgoingEdgeId
}
return block.outgoingEdgeId
}
export const formatReply = (
inputValue: string | undefined,
blockType: BlockType
): string | null => {
if (!inputValue) return null
switch (blockType) {
case InputBlockType.PHONE:
return formatPhoneNumber(inputValue)
}
return inputValue
}
export const isReplyValid = (inputValue: string, block: Block): boolean => {
switch (block.type) {
case InputBlockType.EMAIL:
return validateEmail(inputValue)

View File

@ -2,13 +2,17 @@ import { parseVariables } from '@/features/variables'
import {
BubbleBlock,
BubbleBlockType,
ChatMessageContent,
ChatMessage,
ChatReply,
Group,
InputBlock,
InputBlockType,
RuntimeOptions,
SessionState,
} from 'models'
import {
isBubbleBlock,
isDefined,
isInputBlock,
isIntegrationBlock,
isLogicBlock,
@ -16,6 +20,7 @@ import {
import { executeLogic } from './executeLogic'
import { getNextGroup } from './getNextGroup'
import { executeIntegration } from './executeIntegration'
import { computePaymentInputRuntimeOptions } from '@/features/blocks/inputs/payment/api'
export const executeGroup =
(state: SessionState, currentReply?: ChatReply) =>
@ -33,17 +38,20 @@ export const executeGroup =
nextEdgeId = block.outgoingEdgeId
if (isBubbleBlock(block)) {
messages.push({
type: block.type,
content: parseBubbleBlockContent(newSessionState)(block),
})
messages.push(parseBubbleBlockContent(newSessionState)(block))
continue
}
if (isInputBlock(block))
return {
messages,
input: block,
input: {
...block,
runtimeOptions: await computeRuntimeOptions(newSessionState)(block),
prefilledValue: getPrefilledInputValue(
newSessionState.typebot.variables
)(block),
},
newSessionState: {
...newSessionState,
currentBlock: {
@ -53,9 +61,9 @@ export const executeGroup =
},
}
const executionResponse = isLogicBlock(block)
? await executeLogic(state)(block)
? await executeLogic(newSessionState)(block)
: isIntegrationBlock(block)
? await executeIntegration(state)(block)
? await executeIntegration(newSessionState)(block)
: null
if (!executionResponse) continue
@ -84,30 +92,50 @@ export const executeGroup =
)
}
const computeRuntimeOptions =
(state: SessionState) =>
(block: InputBlock): Promise<RuntimeOptions> | undefined => {
switch (block.type) {
case InputBlockType.PAYMENT: {
return computePaymentInputRuntimeOptions(state)(block.options)
}
}
}
const parseBubbleBlockContent =
({ typebot: { variables } }: SessionState) =>
(block: BubbleBlock): ChatMessageContent => {
(block: BubbleBlock): ChatMessage => {
switch (block.type) {
case BubbleBlockType.TEXT: {
const plainText = parseVariables(variables)(block.content.plainText)
const html = parseVariables(variables)(block.content.html)
return { plainText, html }
return { type: block.type, content: { plainText, html } }
}
case BubbleBlockType.IMAGE: {
const url = parseVariables(variables)(block.content.url)
return { url }
return { type: block.type, content: { ...block.content, url } }
}
case BubbleBlockType.VIDEO: {
const url = parseVariables(variables)(block.content.url)
return { url }
return { type: block.type, content: { ...block.content, url } }
}
case BubbleBlockType.AUDIO: {
const url = parseVariables(variables)(block.content.url)
return { url }
return { type: block.type, content: { ...block.content, url } }
}
case BubbleBlockType.EMBED: {
const url = parseVariables(variables)(block.content.url)
return { url }
return { type: block.type, content: { ...block.content, url } }
}
}
}
const getPrefilledInputValue =
(variables: SessionState['typebot']['variables']) => (block: InputBlock) => {
return (
variables.find(
(variable) =>
variable.id === block.options.variableId && isDefined(variable.value)
)?.value ?? undefined
)
}