2
0

(chat) Improve chat API compatibility with preview mode

This commit is contained in:
Baptiste Arnaud
2023-01-16 12:13:21 +01:00
parent dbe5c3cdb1
commit 7311988901
55 changed files with 4133 additions and 465 deletions

View File

@ -1,33 +1,22 @@
import { getInitialChatReplyQuery } from '@/queries/getInitialChatReplyQuery'
import {
getExistingResultFromSession,
setResultInSession,
} from '@/utils/sessionStorage'
import { Standard } from '@typebot.io/react'
import { BackgroundType, InitialChatReply, Typebot } from 'models'
import { BackgroundType, Typebot } from 'models'
import { useRouter } from 'next/router'
import { useCallback, useEffect, useState } from 'react'
import { ErrorPage } from './ErrorPage'
import { SEO } from './Seo'
export type TypebotPageV2Props = {
url: string
typebot: Pick<
Typebot,
'settings' | 'theme' | 'id' | 'name' | 'isClosed' | 'isArchived'
'settings' | 'theme' | 'name' | 'isClosed' | 'isArchived' | 'publicId'
>
}
let hasInitializedChat = false
export const TypebotPageV2 = ({ url, typebot }: TypebotPageV2Props) => {
const { asPath, push } = useRouter()
const [initialChatReply, setInitialChatReply] = useState<InitialChatReply>()
const [error, setError] = useState<Error | undefined>(undefined)
const background = typebot.theme.general.background
const clearQueryParamsIfNecessary = useCallback(() => {
const clearQueryParamsIfNecessary = () => {
const hasQueryParams = asPath.includes('?')
if (
!hasQueryParams ||
@ -35,44 +24,8 @@ export const TypebotPageV2 = ({ url, typebot }: TypebotPageV2Props) => {
)
return
push(asPath.split('?')[0], undefined, { shallow: true })
}, [asPath, push, typebot.settings.general.isHideQueryParamsEnabled])
useEffect(() => {
console.log(open)
clearQueryParamsIfNecessary()
}, [clearQueryParamsIfNecessary])
useEffect(() => {
if (hasInitializedChat) return
hasInitializedChat = true
const prefilledVariables = extractPrefilledVariables()
const existingResultId = getExistingResultFromSession() ?? undefined
getInitialChatReplyQuery({
typebotId: typebot.id,
resultId:
typebot.settings.general.isNewResultOnRefreshEnabled ?? false
? undefined
: existingResultId,
prefilledVariables,
}).then(({ data, error }) => {
if (error && 'code' in error && error.code === 'FORBIDDEN') {
setError(new Error('This bot is now closed.'))
return
}
if (!data) return setError(new Error("Couldn't initiate the chat"))
setInitialChatReply(data)
setResultInSession(data.resultId)
})
}, [
initialChatReply,
typebot.id,
typebot.settings.general.isNewResultOnRefreshEnabled,
])
if (error) {
return <ErrorPage error={error} />
}
return (
<div
style={{
@ -89,20 +42,12 @@ export const TypebotPageV2 = ({ url, typebot }: TypebotPageV2Props) => {
typebotName={typebot.name}
metadata={typebot.settings.metadata}
/>
{initialChatReply && (
<Standard typebotId={typebot.id} initialChatReply={initialChatReply} />
{typebot.publicId && (
<Standard
typebot={typebot.publicId}
onInit={clearQueryParamsIfNecessary}
/>
)}
</div>
)
}
const extractPrefilledVariables = () => {
const urlParams = new URLSearchParams(location.search)
const prefilledVariables: { [key: string]: string } = {}
urlParams.forEach((value, key) => {
prefilledVariables[key] = value
})
return prefilledVariables
}

View File

@ -3,4 +3,9 @@ import { ChoiceInputBlock } from 'models'
export const validateButtonInput = (
buttonBlock: ChoiceInputBlock,
input: string
) => buttonBlock.items.some((item) => item.content === input)
) =>
input
.split(',')
.every((value) =>
buttonBlock.items.some((item) => item.content === value.trim())
)

View File

@ -2,6 +2,7 @@ import { checkChatsUsage } from '@/features/usage'
import {
parsePrefilledVariables,
deepParseVariable,
parseVariables,
} from '@/features/variables'
import prisma from '@/lib/prisma'
import { publicProcedure } from '@/utils/server/trpc'
@ -11,15 +12,17 @@ import {
ChatReply,
chatReplySchema,
ChatSession,
PublicTypebot,
Result,
sendMessageInputSchema,
SessionState,
StartParams,
StartTypebot,
Theme,
Typebot,
Variable,
} from 'models'
import { continueBotFlow, getSession, startBotFlow } from '../utils'
import { omit } from 'utils'
export const sendMessageProcedure = publicProcedure
.meta({
@ -37,12 +40,13 @@ export const sendMessageProcedure = publicProcedure
const session = sessionId ? await getSession(sessionId) : null
if (!session) {
const { sessionId, typebot, messages, input, resultId } =
const { sessionId, typebot, messages, input, resultId, dynamicTheme } =
await startSession(startParams)
return {
sessionId,
typebot: typebot
? {
id: typebot.id,
theme: typebot.theme,
settings: typebot.settings,
}
@ -50,6 +54,7 @@ export const sendMessageProcedure = publicProcedure
messages,
input,
resultId,
dynamicTheme,
}
} else {
const { messages, input, logic, newSessionState, integrations } =
@ -67,89 +72,35 @@ export const sendMessageProcedure = publicProcedure
input,
logic,
integrations,
dynamicTheme: parseDynamicThemeReply(newSessionState),
}
}
})
const startSession = async (startParams?: StartParams) => {
if (!startParams?.typebotId)
if (!startParams?.typebot)
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,
},
})
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',
message: 'No typebot provided in startParams',
})
if ('isClosed' in typebot && typebot.isClosed)
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Typebot is closed',
})
const hasReachedLimit = !startParams.isPreview
? await checkChatsUsage(startParams.typebotId)
: false
if (hasReachedLimit)
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Your workspace reached its chat limit',
})
const typebot = await getTypebot(startParams)
const startVariables = startParams.prefilledVariables
? parsePrefilledVariables(typebot.variables, startParams.prefilledVariables)
: typebot.variables
const result = await getResult({ ...startParams, startVariables })
const result = await getResult({
...startParams,
typebot: typebot.id,
startVariables,
isNewResultOnRefreshEnabled:
typebot.settings.general.isNewResultOnRefreshEnabled ?? false,
})
const initialState: SessionState = {
typebot: {
id: startParams.typebotId,
id: typebot.id,
groups: typebot.groups,
edges: typebot.edges,
variables: startVariables,
@ -161,8 +112,9 @@ const startSession = async (startParams?: StartParams) => {
result: result
? { id: result.id, variables: result.variables, hasStarted: false }
: undefined,
isPreview: false,
currentTypebotId: startParams.typebotId,
isPreview: startParams.isPreview || typeof startParams.typebot !== 'string',
currentTypebotId: typebot.id,
dynamicTheme: parseDynamicThemeInState(typebot.theme),
}
const {
@ -170,16 +122,26 @@ const startSession = async (startParams?: StartParams) => {
input,
logic,
newSessionState: newInitialState,
} = await startBotFlow(initialState)
} = await startBotFlow(initialState, startParams.startGroupId)
if (!input)
return {
messages,
logic,
typebot: {
id: typebot.id,
settings: deepParseVariable(newInitialState.typebot.variables)(
typebot.settings
),
theme: deepParseVariable(newInitialState.typebot.variables)(
typebot.theme
),
},
dynamicTheme: parseDynamicThemeReply(newInitialState),
}
const sessionState: ChatSession['state'] = {
...(newInitialState ?? initialState),
...newInitialState,
currentBlock: {
groupId: input.groupId,
blockId: input.id,
@ -196,27 +158,122 @@ const startSession = async (startParams?: StartParams) => {
resultId: result?.id,
sessionId: session.id,
typebot: {
settings: deepParseVariable(typebot.variables)(typebot.settings),
theme: deepParseVariable(typebot.variables)(typebot.theme),
id: typebot.id,
settings: deepParseVariable(newInitialState.typebot.variables)(
typebot.settings
),
theme: deepParseVariable(newInitialState.typebot.variables)(
typebot.theme
),
},
messages,
input,
logic,
dynamicTheme: parseDynamicThemeReply(newInitialState),
} satisfies ChatReply
}
const getTypebot = async ({
typebot,
isPreview,
}: Pick<StartParams, 'typebot' | 'isPreview'>): Promise<StartTypebot> => {
if (typeof typebot !== 'string') return typebot
const typebotQuery = isPreview
? await prisma.typebot.findUnique({
where: { id: typebot },
select: {
id: true,
groups: true,
edges: true,
settings: true,
theme: true,
variables: true,
isArchived: true,
},
})
: await prisma.publicTypebot.findFirst({
where: { typebot: { publicId: typebot } },
select: {
groups: true,
edges: true,
settings: true,
theme: true,
variables: true,
typebotId: true,
typebot: {
select: {
isArchived: true,
isClosed: true,
workspace: {
select: {
id: true,
plan: true,
additionalChatsIndex: true,
chatsLimitFirstEmailSentAt: true,
chatsLimitSecondEmailSentAt: true,
customChatsLimit: true,
},
},
},
},
},
})
const parsedTypebot =
typebotQuery && 'typebot' in typebotQuery
? ({
id: typebotQuery.typebotId,
...omit(typebotQuery.typebot, 'workspace'),
...omit(typebotQuery, 'typebot', 'typebotId'),
} as StartTypebot & Pick<Typebot, 'isArchived' | 'isClosed'>)
: (typebotQuery as StartTypebot & Pick<Typebot, 'isArchived'>)
if (
!parsedTypebot ||
('isArchived' in parsedTypebot && parsedTypebot.isArchived)
)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Typebot not found',
})
if ('isClosed' in parsedTypebot && parsedTypebot.isClosed)
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Typebot is closed',
})
const hasReachedLimit =
typebotQuery && 'typebot' in typebotQuery
? await checkChatsUsage({
typebotId: parsedTypebot.id,
workspace: typebotQuery.typebot.workspace,
})
: false
if (hasReachedLimit)
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You have reached your chats limit',
})
return parsedTypebot
}
const getResult = async ({
typebotId,
typebot,
isPreview,
resultId,
startVariables,
}: Pick<StartParams, 'isPreview' | 'resultId' | 'typebotId'> & {
isNewResultOnRefreshEnabled,
}: Pick<StartParams, 'isPreview' | 'resultId' | 'typebot'> & {
startVariables: Variable[]
isNewResultOnRefreshEnabled: boolean
}) => {
if (isPreview) return undefined
if (isPreview || typeof typebot !== 'string') return undefined
const data = {
isCompleted: false,
typebotId: typebotId,
typebotId: typebot,
variables: { set: startVariables.filter((variable) => variable.value) },
} satisfies Prisma.ResultUncheckedCreateInput
const select = {
@ -225,7 +282,7 @@ const getResult = async ({
hasStarted: true,
} satisfies Prisma.ResultSelect
return (
resultId
resultId && !isNewResultOnRefreshEnabled
? await prisma.result.update({
where: { id: resultId },
data,
@ -237,3 +294,36 @@ const getResult = async ({
})
) as Pick<Result, 'id' | 'variables' | 'hasStarted'>
}
const parseDynamicThemeInState = (theme: Theme) => {
const hostAvatarUrl =
theme.chat.hostAvatar?.isEnabled ?? true
? theme.chat.hostAvatar?.url
: undefined
const guestAvatarUrl =
theme.chat.guestAvatar?.isEnabled ?? false
? theme.chat.guestAvatar?.url
: undefined
if (!hostAvatarUrl?.startsWith('{{') && !guestAvatarUrl?.startsWith('{{'))
return
return {
hostAvatarUrl: hostAvatarUrl?.startsWith('{{') ? hostAvatarUrl : undefined,
guestAvatarUrl: guestAvatarUrl?.startsWith('{{')
? guestAvatarUrl
: undefined,
}
}
const parseDynamicThemeReply = (
state: SessionState | undefined
): ChatReply['dynamicTheme'] => {
if (!state?.dynamicTheme) return
return {
hostAvatarUrl: parseVariables(state?.typebot.variables)(
state.dynamicTheme.hostAvatarUrl
),
guestAvatarUrl: parseVariables(state?.typebot.variables)(
state.dynamicTheme.guestAvatarUrl
),
}
}

View File

@ -23,7 +23,7 @@ export const executeGroup =
(state: SessionState, currentReply?: ChatReply) =>
async (
group: Group
): Promise<ChatReply & { newSessionState?: SessionState }> => {
): Promise<ChatReply & { newSessionState: SessionState }> => {
const messages: ChatReply['messages'] = currentReply?.messages ?? []
let logic: ChatReply['logic'] = currentReply?.logic
let integrations: ChatReply['integrations'] = currentReply?.integrations
@ -72,8 +72,10 @@ export const executeGroup =
integrations = executionResponse.integrations
if (executionResponse.newSessionState)
newSessionState = executionResponse.newSessionState
if (executionResponse.outgoingEdgeId)
if (executionResponse.outgoingEdgeId) {
nextEdgeId = executionResponse.outgoingEdgeId
break
}
}
if (!nextEdgeId) return { messages, newSessionState, logic, integrations }

View File

@ -1,13 +1,26 @@
import { TRPCError } from '@trpc/server'
import { ChatReply, SessionState } from 'models'
import { executeGroup } from './executeGroup'
import { getNextGroup } from './getNextGroup'
export const startBotFlow = async (
state: SessionState
): Promise<ChatReply & { newSessionState?: SessionState }> => {
state: SessionState,
startGroupId?: string
): Promise<ChatReply & { newSessionState: SessionState }> => {
if (startGroupId) {
const group = state.typebot.groups.find(
(group) => group.id === startGroupId
)
if (!group)
throw new TRPCError({
code: 'BAD_REQUEST',
message: "startGroupId doesn't exist",
})
return executeGroup(state)(group)
}
const firstEdgeId = state.typebot.groups[0].blocks[0].outgoingEdgeId
if (!firstEdgeId) return { messages: [] }
if (!firstEdgeId) return { messages: [], newSessionState: state }
const nextGroup = getNextGroup(state)(firstEdgeId)
if (!nextGroup) return { messages: [] }
if (!nextGroup) return { messages: [], newSessionState: state }
return executeGroup(state)(nextGroup.group)
}

View File

@ -37,11 +37,10 @@ test('API chat execution should work on preview bot', async ({ request }) => {
await request.post(`/api/v1/sendMessage`, {
data: {
startParams: {
typebotId,
typebot: typebotId,
isPreview: true,
},
// TODO: replace with satisfies once compatible with playwright
} as SendMessageInput,
} satisfies SendMessageInput,
})
).json()
expect(resultId).toBeUndefined()
@ -75,10 +74,9 @@ test('API chat execution should work on published bot', async ({ request }) => {
await request.post(`/api/v1/sendMessage`, {
data: {
startParams: {
typebotId,
typebot: publicId,
},
// TODO: replace with satisfies once compatible with playwright
} as SendMessageInput,
} satisfies SendMessageInput,
})
).json()
chatSessionId = sessionId

View File

@ -27,8 +27,8 @@ test('Result should be overwritten on page refresh', async ({ page }) => {
])
const { resultId } = await response.json()
expect(resultId).toBeDefined()
await expect(page.getByRole('textbox')).toBeVisible()
const [, secondResponse] = await Promise.all([
page.reload(),
page.waitForResponse(/sendMessage/),

View File

@ -4,30 +4,44 @@ import {
sendAlmostReachedChatsLimitEmail,
sendReachedChatsLimitEmail,
} from 'emails'
import { Workspace } from 'models'
import { env, getChatsLimit, isDefined } from 'utils'
const LIMIT_EMAIL_TRIGGER_PERCENT = 0.8
export const checkChatsUsage = async (typebotId: string) => {
const typebot = await prisma.typebot.findUnique({
where: {
id: typebotId,
},
include: {
workspace: {
select: {
id: true,
plan: true,
additionalChatsIndex: true,
chatsLimitFirstEmailSentAt: true,
chatsLimitSecondEmailSentAt: true,
customChatsLimit: true,
export const checkChatsUsage = async (props: {
typebotId: string
workspace?: Pick<
Workspace,
| 'id'
| 'plan'
| 'additionalChatsIndex'
| 'chatsLimitFirstEmailSentAt'
| 'chatsLimitSecondEmailSentAt'
| 'customChatsLimit'
>
}) => {
const typebot = props.workspace
? null
: await prisma.typebot.findUnique({
where: {
id: props.typebotId,
},
},
},
})
include: {
workspace: {
select: {
id: true,
plan: true,
additionalChatsIndex: true,
chatsLimitFirstEmailSentAt: true,
chatsLimitSecondEmailSentAt: true,
customChatsLimit: true,
},
},
},
})
const workspace = typebot?.workspace
const workspace = props.workspace || typebot?.workspace
if (!workspace) return false

View File

@ -26,7 +26,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
}
if (req.method === 'POST') {
const typebotId = req.query.typebotId as string
const hasReachedLimit = await checkChatsUsage(typebotId)
const hasReachedLimit = await checkChatsUsage({ typebotId })
if (hasReachedLimit) return res.send({ result: null, hasReachedLimit })
const result = await prisma.result.create({
data: {

View File

@ -56,12 +56,12 @@ const getTypebotFromPublicId = async (
const typebot = (await prisma.typebot.findUnique({
where: { publicId },
select: {
id: true,
theme: true,
name: true,
settings: true,
isArchived: true,
isClosed: true,
publicId: true,
},
})) as TypebotPageV2Props['typebot'] | null
if (isNotDefined(typebot)) return null

View File

@ -1,28 +0,0 @@
import { InitialChatReply, SendMessageInput } from 'models'
import { sendRequest } from 'utils'
type Props = {
typebotId: string
resultId?: string
prefilledVariables?: Record<string, string>
}
export async function getInitialChatReplyQuery({
typebotId,
resultId,
prefilledVariables,
}: Props) {
if (!typebotId)
throw new Error('Typebot ID is required to get initial messages')
return sendRequest<InitialChatReply>({
method: 'POST',
url: `/api/v1/sendMessage`,
body: {
startParams: {
typebotId,
resultId,
prefilledVariables,
},
} satisfies SendMessageInput,
})
}