⚗️ Implement chat API
This commit is contained in:
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)
|
||||
}
|
Reference in New Issue
Block a user