2
0

⚗️ Implement chat API

This commit is contained in:
Baptiste Arnaud
2022-11-29 10:02:40 +01:00
parent 49ba434350
commit bf0d0c2475
122 changed files with 5075 additions and 292 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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