2
0

🧑‍💻 (chat) Introduce startChat and continueChat endpoints

Closes #1030
This commit is contained in:
Baptiste Arnaud
2023-11-13 15:27:36 +01:00
parent 63233eb7ee
commit 084588a086
74 changed files with 28426 additions and 645 deletions

View File

@@ -4,8 +4,8 @@ import * as Sentry from '@sentry/nextjs'
import { User } from '@typebot.io/prisma'
import { NextApiRequest, NextApiResponse } from 'next'
import { getServerSession } from 'next-auth'
import { mockedUser } from '../mockedUser'
import { env } from '@typebot.io/env'
import { mockedUser } from '@typebot.io/lib/mockedUser'
export const getAuthenticatedUser = async (
req: NextApiRequest,

View File

@@ -1,16 +0,0 @@
import { User } from '@typebot.io/prisma'
export const mockedUser: User = {
id: 'userId',
name: 'John Doe',
email: 'user@email.com',
company: null,
createdAt: new Date('2022-01-01'),
emailVerified: null,
graphNavigation: 'TRACKPAD',
preferredAppAppearance: null,
image: 'https://avatars.githubusercontent.com/u/16015833?v=4',
lastActivityAt: new Date('2022-01-01'),
onboardingCategories: [],
updatedAt: new Date('2022-01-01'),
}

View File

@@ -12,8 +12,6 @@ export const AudioBubbleNode = ({ url }: Props) => {
return isDefined(url) ? (
<audio src={url} controls />
) : (
<Text color={'gray.500'}>
{t('editor.blocks.bubbles.audio.node.clickToEdit.text')}
</Text>
<Text color={'gray.500'}>{t('clickToEdit')}</Text>
)
}

View File

@@ -9,10 +9,6 @@ type Props = {
export const EmbedBubbleContent = ({ block }: Props) => {
const { t } = useTranslate()
if (!block.content?.url)
return (
<Text color="gray.500">
{t('editor.blocks.bubbles.embed.node.clickToEdit.text')}
</Text>
)
return <Text color="gray.500">{t('clickToEdit')}</Text>
return <Text>{t('editor.blocks.bubbles.embed.node.show.text')}</Text>
}

View File

@@ -11,9 +11,7 @@ export const ImageBubbleContent = ({ block }: Props) => {
const containsVariables =
block.content?.url?.includes('{{') && block.content.url.includes('}}')
return !block.content?.url ? (
<Text color={'gray.500'}>
{t('editor.blocks.bubbles.image.node.clickToEdit.text')}
</Text>
<Text color={'gray.500'}>{t('clickToEdit')}</Text>
) : (
<Box w="full">
<Image

View File

@@ -13,11 +13,7 @@ type Props = {
export const VideoBubbleContent = ({ block }: Props) => {
const { t } = useTranslate()
if (!block.content?.url || !block.content.type)
return (
<Text color="gray.500">
{t('editor.blocks.bubbles.video.node.clickToEdit.text')}
</Text>
)
return <Text color="gray.500">{t('clickToEdit')}</Text>
const containsVariables =
block.content?.url?.includes('{{') && block.content.url.includes('}}')
switch (block.content.type) {

View File

@@ -32,7 +32,7 @@ test('should be configurable', async ({ page }) => {
await expect(page.getByTestId('selected-item-label').first()).toHaveText(
'My link typebot 2'
)
await page.click('input[placeholder="Select a block"]')
await page.click('input[placeholder="Select a group"]')
await page.click('text=Group #2')
await page.click('text=Preview')

View File

@@ -4,7 +4,7 @@ import { useTypebot } from '@/features/editor/providers/TypebotProvider'
import { useGraph } from '@/features/graph/providers/GraphProvider'
import { useToast } from '@/hooks/useToast'
import { Standard } from '@typebot.io/nextjs'
import { ChatReply } from '@typebot.io/schemas'
import { ContinueChatResponse } from '@typebot.io/schemas'
export const WebPreview = () => {
const { typebot } = useTypebot()
@@ -13,7 +13,7 @@ export const WebPreview = () => {
const { showToast } = useToast()
const handleNewLogs = (logs: ChatReply['logs']) => {
const handleNewLogs = (logs: ContinueChatResponse['logs']) => {
logs?.forEach((log) => {
showToast({
icon: <WebhookIcon />,
@@ -40,8 +40,13 @@ export const WebPreview = () => {
<Standard
key={`web-preview${startPreviewAtGroup ?? ''}`}
typebot={typebot}
startGroupId={startPreviewAtGroup}
startEventId={startPreviewAtEvent}
startFrom={
startPreviewAtGroup
? { type: 'group', groupId: startPreviewAtGroup }
: startPreviewAtEvent
? { type: 'event', eventId: startPreviewAtEvent }
: undefined
}
onNewInputBlock={(block) =>
setPreviewingBlock({
id: block.id,

View File

@@ -24,7 +24,7 @@ import { BuoyIcon, ExternalLinkIcon } from '@/components/icons'
export const WhatsAppPreviewInstructions = (props: StackProps) => {
const { typebot, save } = useTypebot()
const { startPreviewAtGroup } = useEditor()
const { startPreviewAtGroup, startPreviewAtEvent } = useEditor()
const [phoneNumber, setPhoneNumber] = useState(
getPhoneNumberFromLocalStorage() ?? ''
)
@@ -56,7 +56,11 @@ export const WhatsAppPreviewInstructions = (props: StackProps) => {
mutate({
to: phoneNumber,
typebotId: typebot.id,
startGroupId: startPreviewAtGroup,
startFrom: startPreviewAtGroup
? { type: 'group', groupId: startPreviewAtGroup }
: startPreviewAtEvent
? { type: 'event', eventId: startPreviewAtEvent }
: undefined,
})
}

View File

@@ -10,7 +10,7 @@ import { restartSession } from '@typebot.io/bot-engine/queries/restartSession'
import { sendChatReplyToWhatsApp } from '@typebot.io/bot-engine/whatsapp/sendChatReplyToWhatsApp'
import { sendWhatsAppMessage } from '@typebot.io/bot-engine/whatsapp/sendWhatsAppMessage'
import { isReadTypebotForbidden } from '../typebot/helpers/isReadTypebotForbidden'
import { SessionState } from '@typebot.io/schemas'
import { SessionState, startFromSchema } from '@typebot.io/schemas'
export const startWhatsAppPreview = authenticatedProcedure
.meta({
@@ -31,7 +31,7 @@ export const startWhatsAppPreview = authenticatedProcedure
value.replace(/\s/g, '').replace(/\+/g, '').replace(/-/g, '')
),
typebotId: z.string(),
startGroupId: z.string().optional(),
startFrom: startFromSchema.optional(),
})
)
.output(
@@ -39,135 +39,133 @@ export const startWhatsAppPreview = authenticatedProcedure
message: z.string(),
})
)
.mutation(
async ({ input: { to, typebotId, startGroupId }, ctx: { user } }) => {
if (
!env.WHATSAPP_PREVIEW_FROM_PHONE_NUMBER_ID ||
!env.META_SYSTEM_USER_TOKEN ||
!env.WHATSAPP_PREVIEW_TEMPLATE_NAME
)
throw new TRPCError({
code: 'BAD_REQUEST',
message:
'Missing WHATSAPP_PREVIEW_FROM_PHONE_NUMBER_ID or META_SYSTEM_USER_TOKEN or WHATSAPP_PREVIEW_TEMPLATE_NAME env variables',
})
.mutation(async ({ input: { to, typebotId, startFrom }, ctx: { user } }) => {
if (
!env.WHATSAPP_PREVIEW_FROM_PHONE_NUMBER_ID ||
!env.META_SYSTEM_USER_TOKEN ||
!env.WHATSAPP_PREVIEW_TEMPLATE_NAME
)
throw new TRPCError({
code: 'BAD_REQUEST',
message:
'Missing WHATSAPP_PREVIEW_FROM_PHONE_NUMBER_ID or META_SYSTEM_USER_TOKEN or WHATSAPP_PREVIEW_TEMPLATE_NAME env variables',
})
const existingTypebot = await prisma.typebot.findFirst({
where: {
id: typebotId,
},
select: {
id: true,
workspaceId: true,
collaborators: {
select: {
userId: true,
},
const existingTypebot = await prisma.typebot.findFirst({
where: {
id: typebotId,
},
select: {
id: true,
workspaceId: true,
collaborators: {
select: {
userId: true,
},
},
})
if (
!existingTypebot?.id ||
(await isReadTypebotForbidden(existingTypebot, user))
)
throw new TRPCError({ code: 'NOT_FOUND', message: 'Typebot not found' })
},
})
if (
!existingTypebot?.id ||
(await isReadTypebotForbidden(existingTypebot, user))
)
throw new TRPCError({ code: 'NOT_FOUND', message: 'Typebot not found' })
const sessionId = `wa-preview-${to}`
const sessionId = `wa-preview-${to}`
const existingSession = await prisma.chatSession.findFirst({
where: {
id: sessionId,
},
select: {
updatedAt: true,
state: true,
},
})
const existingSession = await prisma.chatSession.findFirst({
where: {
id: sessionId,
},
select: {
updatedAt: true,
state: true,
},
})
// For users that did not interact with the bot in the last 24 hours, we need to send a template message.
const canSendDirectMessagesToUser =
(existingSession?.updatedAt.getTime() ?? 0) >
Date.now() - 24 * 60 * 60 * 1000
// For users that did not interact with the bot in the last 24 hours, we need to send a template message.
const canSendDirectMessagesToUser =
(existingSession?.updatedAt.getTime() ?? 0) >
Date.now() - 24 * 60 * 60 * 1000
const {
newSessionState,
const {
newSessionState,
messages,
input,
clientSideActions,
logs,
visitedEdges,
} = await startSession({
version: 2,
message: undefined,
startParams: {
isOnlyRegistering: !canSendDirectMessagesToUser,
type: 'preview',
typebotId,
startFrom,
userId: user.id,
},
initialSessionState: {
whatsApp: (existingSession?.state as SessionState | undefined)
?.whatsApp,
},
})
if (canSendDirectMessagesToUser) {
await sendChatReplyToWhatsApp({
to,
typingEmulation: newSessionState.typingEmulation,
messages,
input,
clientSideActions,
logs,
visitedEdges,
} = await startSession({
version: 2,
message: undefined,
startParams: {
isOnlyRegistering: !canSendDirectMessagesToUser,
typebot: typebotId,
isPreview: true,
startGroupId,
},
userId: user.id,
initialSessionState: {
whatsApp: (existingSession?.state as SessionState | undefined)
?.whatsApp,
credentials: {
phoneNumberId: env.WHATSAPP_PREVIEW_FROM_PHONE_NUMBER_ID,
systemUserAccessToken: env.META_SYSTEM_USER_TOKEN,
},
state: newSessionState,
})
if (canSendDirectMessagesToUser) {
await sendChatReplyToWhatsApp({
await saveStateToDatabase({
clientSideActions: [],
input,
logs,
session: {
id: sessionId,
state: newSessionState,
},
visitedEdges,
})
} else {
await restartSession({
state: newSessionState,
id: sessionId,
})
try {
await sendWhatsAppMessage({
to,
typingEmulation: newSessionState.typingEmulation,
messages,
input,
clientSideActions,
message: {
type: 'template',
template: {
language: {
code: env.WHATSAPP_PREVIEW_TEMPLATE_LANG,
},
name: env.WHATSAPP_PREVIEW_TEMPLATE_NAME,
},
},
credentials: {
phoneNumberId: env.WHATSAPP_PREVIEW_FROM_PHONE_NUMBER_ID,
systemUserAccessToken: env.META_SYSTEM_USER_TOKEN,
},
state: newSessionState,
})
await saveStateToDatabase({
clientSideActions: [],
input,
logs,
session: {
id: sessionId,
state: newSessionState,
},
visitedEdges,
} catch (err) {
if (err instanceof HTTPError) console.log(err.response.body)
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Request to Meta to send preview message failed',
cause: err,
})
} else {
await restartSession({
state: newSessionState,
id: sessionId,
})
try {
await sendWhatsAppMessage({
to,
message: {
type: 'template',
template: {
language: {
code: env.WHATSAPP_PREVIEW_TEMPLATE_LANG,
},
name: env.WHATSAPP_PREVIEW_TEMPLATE_NAME,
},
},
credentials: {
phoneNumberId: env.WHATSAPP_PREVIEW_FROM_PHONE_NUMBER_ID,
systemUserAccessToken: env.META_SYSTEM_USER_TOKEN,
},
})
} catch (err) {
if (err instanceof HTTPError) console.log(err.response.body)
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Request to Meta to send preview message failed',
cause: err,
})
}
}
return {
message: 'success',
}
}
)
return {
message: 'success',
}
})

View File

@@ -11,7 +11,7 @@ import { NextApiRequest, NextApiResponse } from 'next'
import { customAdapter } from '../../../features/auth/api/customAdapter'
import { User } from '@typebot.io/prisma'
import { getAtPath, isDefined } from '@typebot.io/lib'
import { mockedUser } from '@/features/auth/mockedUser'
import { mockedUser } from '@typebot.io/lib/mockedUser'
import { getNewUserInvitations } from '@/features/auth/helpers/getNewUserInvitations'
import { sendVerificationRequest } from '@/features/auth/helpers/sendVerificationRequest'
import { Ratelimit } from '@upstash/ratelimit'

View File

@@ -624,7 +624,9 @@
"enum": [
"url",
"youtube",
"vimeo"
"vimeo",
"tiktok",
"gumlet"
]
},
"height": {
@@ -634,6 +636,12 @@
},
{}
]
},
"aspectRatio": {
"type": "string"
},
"maxWidth": {
"type": "string"
}
},
"additionalProperties": false
@@ -3929,7 +3937,9 @@
"enum": [
"url",
"youtube",
"vimeo"
"vimeo",
"tiktok",
"gumlet"
]
},
"height": {
@@ -3939,6 +3949,12 @@
},
{}
]
},
"aspectRatio": {
"type": "string"
},
"maxWidth": {
"type": "string"
}
},
"additionalProperties": false
@@ -8007,7 +8023,9 @@
"enum": [
"url",
"youtube",
"vimeo"
"vimeo",
"tiktok",
"gumlet"
]
},
"height": {
@@ -8017,6 +8035,12 @@
},
{}
]
},
"aspectRatio": {
"type": "string"
},
"maxWidth": {
"type": "string"
}
},
"additionalProperties": false
@@ -11708,7 +11732,9 @@
"enum": [
"url",
"youtube",
"vimeo"
"vimeo",
"tiktok",
"gumlet"
]
},
"height": {
@@ -11718,6 +11744,12 @@
},
{}
]
},
"aspectRatio": {
"type": "string"
},
"maxWidth": {
"type": "string"
}
},
"additionalProperties": false
@@ -15497,7 +15529,9 @@
"enum": [
"url",
"youtube",
"vimeo"
"vimeo",
"tiktok",
"gumlet"
]
},
"height": {
@@ -15507,6 +15541,12 @@
},
{}
]
},
"aspectRatio": {
"type": "string"
},
"maxWidth": {
"type": "string"
}
},
"additionalProperties": false
@@ -19195,7 +19235,9 @@
"enum": [
"url",
"youtube",
"vimeo"
"vimeo",
"tiktok",
"gumlet"
]
},
"height": {
@@ -19205,6 +19247,12 @@
},
{}
]
},
"aspectRatio": {
"type": "string"
},
"maxWidth": {
"type": "string"
}
},
"additionalProperties": false
@@ -22918,7 +22966,9 @@
"enum": [
"url",
"youtube",
"vimeo"
"vimeo",
"tiktok",
"gumlet"
]
},
"height": {
@@ -22928,6 +22978,12 @@
},
{}
]
},
"aspectRatio": {
"type": "string"
},
"maxWidth": {
"type": "string"
}
},
"additionalProperties": false
@@ -26647,7 +26703,9 @@
"enum": [
"url",
"youtube",
"vimeo"
"vimeo",
"tiktok",
"gumlet"
]
},
"height": {
@@ -26657,6 +26715,12 @@
},
{}
]
},
"aspectRatio": {
"type": "string"
},
"maxWidth": {
"type": "string"
}
},
"additionalProperties": false
@@ -30203,7 +30267,6 @@
"version",
"id",
"name",
"events",
"groups",
"edges",
"variables",
@@ -30425,7 +30488,9 @@
"enum": [
"url",
"youtube",
"vimeo"
"vimeo",
"tiktok",
"gumlet"
]
},
"height": {
@@ -30435,6 +30500,12 @@
},
{}
]
},
"aspectRatio": {
"type": "string"
},
"maxWidth": {
"type": "string"
}
},
"additionalProperties": false
@@ -34213,7 +34284,9 @@
"enum": [
"url",
"youtube",
"vimeo"
"vimeo",
"tiktok",
"gumlet"
]
},
"height": {
@@ -34223,6 +34296,12 @@
},
{}
]
},
"aspectRatio": {
"type": "string"
},
"maxWidth": {
"type": "string"
}
},
"additionalProperties": false
@@ -37878,7 +37957,9 @@
"enum": [
"url",
"youtube",
"vimeo"
"vimeo",
"tiktok",
"gumlet"
]
},
"height": {
@@ -37888,6 +37969,12 @@
},
{}
]
},
"aspectRatio": {
"type": "string"
},
"maxWidth": {
"type": "string"
}
},
"additionalProperties": false
@@ -41678,7 +41765,9 @@
"enum": [
"url",
"youtube",
"vimeo"
"vimeo",
"tiktok",
"gumlet"
]
},
"height": {
@@ -41688,6 +41777,12 @@
},
{}
]
},
"aspectRatio": {
"type": "string"
},
"maxWidth": {
"type": "string"
}
},
"additionalProperties": false
@@ -45166,10 +45261,6 @@
"type": "string",
"nullable": true
},
"publicId": {
"type": "string",
"nullable": true
},
"resultsTablePreferences": {
"type": "object",
"properties": {
@@ -45208,15 +45299,13 @@
"required": [
"version",
"name",
"events",
"groups",
"edges",
"variables",
"theme",
"settings",
"icon",
"folderId",
"publicId"
"folderId"
],
"additionalProperties": false
},
@@ -45418,7 +45507,9 @@
"enum": [
"url",
"youtube",
"vimeo"
"vimeo",
"tiktok",
"gumlet"
]
},
"height": {
@@ -45428,6 +45519,12 @@
},
{}
]
},
"aspectRatio": {
"type": "string"
},
"maxWidth": {
"type": "string"
}
},
"additionalProperties": false
@@ -48816,10 +48913,6 @@
"type": "string",
"nullable": true
},
"publicId": {
"type": "string",
"nullable": true
},
"resultsTablePreferences": {
"type": "object",
"properties": {
@@ -48865,8 +48958,7 @@
"theme",
"settings",
"icon",
"folderId",
"publicId"
"folderId"
],
"additionalProperties": false
}
@@ -49092,7 +49184,9 @@
"enum": [
"url",
"youtube",
"vimeo"
"vimeo",
"tiktok",
"gumlet"
]
},
"height": {
@@ -49102,6 +49196,12 @@
},
{}
]
},
"aspectRatio": {
"type": "string"
},
"maxWidth": {
"type": "string"
}
},
"additionalProperties": false
@@ -55954,8 +56054,47 @@
"type": "string",
"minLength": 1
},
"startGroupId": {
"type": "string"
"startFrom": {
"anyOf": [
{
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": [
"group"
]
},
"groupId": {
"type": "string"
}
},
"required": [
"type",
"groupId"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": [
"event"
]
},
"eventId": {
"type": "string"
}
},
"required": [
"type",
"eventId"
],
"additionalProperties": false
}
]
}
},
"required": [

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,79 @@
import { publicProcedure } from '@/helpers/server/trpc'
import { continueChatResponseSchema } from '@typebot.io/schemas/features/chat/schema'
import { TRPCError } from '@trpc/server'
import { getSession } from '@typebot.io/bot-engine/queries/getSession'
import { saveStateToDatabase } from '@typebot.io/bot-engine/saveStateToDatabase'
import { continueBotFlow } from '@typebot.io/bot-engine/continueBotFlow'
import { parseDynamicTheme } from '@typebot.io/bot-engine/parseDynamicTheme'
import { isDefined } from '@typebot.io/lib/utils'
import { z } from 'zod'
export const continueChat = publicProcedure
.meta({
openapi: {
method: 'POST',
path: '/v1/sessions/{sessionId}/continueChat',
summary: 'Continue chat',
description:
'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({
message: z.string().optional(),
sessionId: z.string(),
})
)
.output(continueChatResponseSchema)
.mutation(async ({ input: { sessionId, message } }) => {
const session = await getSession(sessionId)
if (!session) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Session not found.',
})
}
const isSessionExpired =
session &&
isDefined(session.state.expiryTimeout) &&
session.updatedAt.getTime() + session.state.expiryTimeout < Date.now()
if (isSessionExpired)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Session expired. You need to start a new session.',
})
const {
messages,
input,
clientSideActions,
newSessionState,
logs,
lastMessageNewFormat,
visitedEdges,
} = await continueBotFlow(message, { version: 2, state: session.state })
if (newSessionState)
await saveStateToDatabase({
session: {
id: session.id,
state: newSessionState,
},
input,
logs,
clientSideActions,
visitedEdges,
})
return {
messages,
input,
clientSideActions,
dynamicTheme: parseDynamicTheme(newSessionState),
logs,
lastMessageNewFormat,
}
})

View File

@@ -1,8 +1,8 @@
import { publicProcedure } from '@/helpers/server/trpc'
import {
chatReplySchema,
sendMessageInputSchema,
} from '@typebot.io/schemas/features/chat/schema'
chatReplySchema,
} from '@typebot.io/schemas/features/chat/legacy/schema'
import { TRPCError } from '@trpc/server'
import { getSession } from '@typebot.io/bot-engine/queries/getSession'
import { startSession } from '@typebot.io/bot-engine/startSession'
@@ -16,10 +16,12 @@ export const sendMessageV1 = publicProcedure
.meta({
openapi: {
method: 'POST',
path: '/sendMessage',
path: '/v1/sendMessage',
summary: 'Send a message',
description:
'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.',
tags: ['Deprecated'],
deprecated: true,
},
})
.input(sendMessageInputSchema)
@@ -60,8 +62,45 @@ export const sendMessageV1 = publicProcedure
visitedEdges,
} = await startSession({
version: 1,
startParams,
userId: user?.id,
startParams:
startParams.isPreview || typeof startParams.typebot !== 'string'
? {
type: 'preview',
isOnlyRegistering: startParams.isOnlyRegistering ?? false,
isStreamEnabled: startParams.isStreamEnabled,
startFrom:
'startGroupId' in startParams && startParams.startGroupId
? {
type: 'group',
groupId: startParams.startGroupId,
}
: 'startEventId' in startParams &&
startParams.startEventId
? {
type: 'event',
eventId: startParams.startEventId,
}
: undefined,
typebotId:
typeof startParams.typebot === 'string'
? startParams.typebot
: startParams.typebot.id,
typebot:
typeof startParams.typebot === 'string'
? undefined
: startParams.typebot,
message,
userId: parseUserId(user?.id),
}
: {
type: 'live',
isOnlyRegistering: startParams.isOnlyRegistering ?? false,
isStreamEnabled: startParams.isStreamEnabled,
publicId: startParams.typebot,
prefilledVariables: startParams.prefilledVariables,
resultId: startParams.resultId,
message,
},
message,
})
@@ -133,3 +172,13 @@ export const sendMessageV1 = publicProcedure
}
}
)
const parseUserId = (userId?: string): string => {
if (!userId)
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'You need to be authenticated to perform this action',
})
return userId
}

View File

@@ -1,8 +1,4 @@
import { publicProcedure } from '@/helpers/server/trpc'
import {
chatReplySchema,
sendMessageInputSchema,
} from '@typebot.io/schemas/features/chat/schema'
import { TRPCError } from '@trpc/server'
import { getSession } from '@typebot.io/bot-engine/queries/getSession'
import { startSession } from '@typebot.io/bot-engine/startSession'
@@ -11,15 +7,21 @@ import { restartSession } from '@typebot.io/bot-engine/queries/restartSession'
import { continueBotFlow } from '@typebot.io/bot-engine/continueBotFlow'
import { parseDynamicTheme } from '@typebot.io/bot-engine/parseDynamicTheme'
import { isDefined } from '@typebot.io/lib/utils'
import {
chatReplySchema,
sendMessageInputSchema,
} from '@typebot.io/schemas/features/chat/legacy/schema'
export const sendMessageV2 = publicProcedure
.meta({
openapi: {
method: 'POST',
path: '/sendMessage',
path: '/v2/sendMessage',
summary: 'Send a message',
description:
'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.',
tags: ['Deprecated'],
deprecated: true,
},
})
.input(sendMessageInputSchema)
@@ -60,8 +62,45 @@ export const sendMessageV2 = publicProcedure
visitedEdges,
} = await startSession({
version: 2,
startParams,
userId: user?.id,
startParams:
startParams.isPreview || typeof startParams.typebot !== 'string'
? {
type: 'preview',
isOnlyRegistering: startParams.isOnlyRegistering ?? false,
isStreamEnabled: startParams.isStreamEnabled,
startFrom:
'startGroupId' in startParams && startParams.startGroupId
? {
type: 'group',
groupId: startParams.startGroupId,
}
: 'startEventId' in startParams &&
startParams.startEventId
? {
type: 'event',
eventId: startParams.startEventId,
}
: undefined,
typebotId:
typeof startParams.typebot === 'string'
? startParams.typebot
: startParams.typebot.id,
typebot:
typeof startParams.typebot === 'string'
? undefined
: startParams.typebot,
message,
userId: parseUserId(user?.id),
}
: {
type: 'live',
isOnlyRegistering: startParams.isOnlyRegistering ?? false,
isStreamEnabled: startParams.isStreamEnabled,
publicId: startParams.typebot,
prefilledVariables: startParams.prefilledVariables,
resultId: startParams.resultId,
message,
},
message,
})
@@ -133,3 +172,13 @@ export const sendMessageV2 = publicProcedure
}
}
)
const parseUserId = (userId?: string): string => {
if (!userId)
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'You need to be authenticated to perform this action',
})
return userId
}

View File

@@ -0,0 +1,63 @@
import { publicProcedure } from '@/helpers/server/trpc'
import { chatLogSchema } from '@typebot.io/schemas/features/chat/schema'
import { TRPCError } from '@trpc/server'
import { getSession } from '@typebot.io/bot-engine/queries/getSession'
import { z } from 'zod'
import { saveLogs } from '@typebot.io/bot-engine/queries/saveLogs'
import { formatLogDetails } from '@typebot.io/bot-engine/logs/helpers/formatLogDetails'
import * as Sentry from '@sentry/nextjs'
export const saveClientLogs = publicProcedure
.meta({
openapi: {
method: 'POST',
path: '/v1/sessions/{sessionId}/clientLogs',
summary: 'Save client logs',
},
})
.input(
z.object({
sessionId: z.string(),
clientLogs: z.array(chatLogSchema),
})
)
.output(z.object({ message: z.string() }))
.mutation(async ({ input: { sessionId, clientLogs } }) => {
const session = await getSession(sessionId)
if (!session) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Session not found.',
})
}
const resultId = session.state.typebotsQueue[0].resultId
if (!resultId) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Result not found.',
})
}
try {
await saveLogs(
clientLogs.map((log) => ({
...log,
resultId,
details: formatLogDetails(log.details),
}))
)
return {
message: 'Logs successfully saved.',
}
} catch (e) {
console.error('Failed to save logs', e)
Sentry.captureException(e)
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Failed to save logs.',
})
}
})

View File

@@ -0,0 +1,83 @@
import { publicProcedure } from '@/helpers/server/trpc'
import {
startChatInputSchema,
startChatResponseSchema,
} from '@typebot.io/schemas/features/chat/schema'
import { startSession } from '@typebot.io/bot-engine/startSession'
import { saveStateToDatabase } from '@typebot.io/bot-engine/saveStateToDatabase'
import { restartSession } from '@typebot.io/bot-engine/queries/restartSession'
export const startChat = publicProcedure
.meta({
openapi: {
method: 'POST',
path: '/v1/typebots/{publicId}/startChat',
summary: 'Start chat',
},
})
.input(startChatInputSchema)
.output(startChatResponseSchema)
.mutation(
async ({
input: {
message,
isOnlyRegistering,
publicId,
isStreamEnabled,
prefilledVariables,
resultId: startResultId,
},
}) => {
const {
typebot,
messages,
input,
resultId,
dynamicTheme,
logs,
clientSideActions,
newSessionState,
visitedEdges,
} = await startSession({
version: 2,
startParams: {
type: 'live',
isOnlyRegistering,
isStreamEnabled,
publicId,
prefilledVariables,
resultId: startResultId,
},
message,
})
const session = isOnlyRegistering
? await restartSession({
state: newSessionState,
})
: await saveStateToDatabase({
session: {
state: newSessionState,
},
input,
logs,
clientSideActions,
visitedEdges,
})
return {
sessionId: session.id,
typebot: {
id: typebot.id,
theme: typebot.theme,
settings: typebot.settings,
},
messages,
input,
resultId,
dynamicTheme,
logs,
clientSideActions,
}
}
)

View File

@@ -0,0 +1,83 @@
import { authenticatedProcedure } from '@/helpers/server/trpc'
import {
startPreviewChatInputSchema,
startPreviewChatResponseSchema,
} from '@typebot.io/schemas/features/chat/schema'
import { startSession } from '@typebot.io/bot-engine/startSession'
import { saveStateToDatabase } from '@typebot.io/bot-engine/saveStateToDatabase'
import { restartSession } from '@typebot.io/bot-engine/queries/restartSession'
export const startChatPreview = authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/v1/typebots/{typebotId}/preview/startChat',
summary: 'Start preview chat',
},
})
.input(startPreviewChatInputSchema)
.output(startPreviewChatResponseSchema)
.mutation(
async ({
input: {
message,
isOnlyRegistering,
isStreamEnabled,
startFrom,
typebotId,
typebot: startTypebot,
},
ctx: { user },
}) => {
const {
typebot,
messages,
input,
dynamicTheme,
logs,
clientSideActions,
newSessionState,
visitedEdges,
} = await startSession({
version: 2,
startParams: {
type: 'preview',
isOnlyRegistering,
isStreamEnabled,
startFrom,
typebotId,
typebot: startTypebot,
userId: user.id,
},
message,
})
const session = isOnlyRegistering
? await restartSession({
state: newSessionState,
})
: await saveStateToDatabase({
session: {
state: newSessionState,
},
input,
logs,
clientSideActions,
visitedEdges,
})
return {
sessionId: session.id,
typebot: {
id: typebot.id,
theme: typebot.theme,
settings: typebot.settings,
},
messages,
input,
dynamicTheme,
logs,
clientSideActions,
}
}
)

View File

@@ -1,4 +1,3 @@
import { publicProcedure } from '@/helpers/server/trpc'
import { TRPCError } from '@trpc/server'
import { z } from 'zod'
import { getSession } from '@typebot.io/bot-engine/queries/getSession'
@@ -9,12 +8,13 @@ import {
Variable,
} from '@typebot.io/schemas'
import prisma from '@typebot.io/lib/prisma'
import { authenticatedProcedure } from '@/helpers/server/trpc'
export const updateTypebotInSession = publicProcedure
export const updateTypebotInSession = authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/sessions/{sessionId}/updateTypebot',
path: '/v1/sessions/{sessionId}/updateTypebot',
summary: 'Update typebot in session',
description:
'Update chat session with latest typebot modifications. This is useful when you want to update the typebot in an ongoing session after making changes to it.',
@@ -28,8 +28,6 @@ export const updateTypebotInSession = publicProcedure
)
.output(z.object({ message: z.literal('success') }))
.mutation(async ({ input: { sessionId }, ctx: { user } }) => {
if (!user)
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Unauthorized' })
const session = await getSession(sessionId)
if (!session)
throw new TRPCError({ code: 'NOT_FOUND', message: 'Session not found' })

View File

@@ -19,10 +19,11 @@ export const getUploadUrl = publicProcedure
.meta({
openapi: {
method: 'GET',
path: '/typebots/{typebotId}/blocks/{blockId}/storage/upload-url',
path: '/v1/typebots/{typebotId}/blocks/{blockId}/storage/upload-url',
summary: 'Get upload URL for a file',
description: 'Used for the web client to get the bucket upload file.',
deprecated: true,
tags: ['Deprecated'],
},
})
.input(

View File

@@ -13,7 +13,7 @@ export const generateUploadUrl = publicProcedure
.meta({
openapi: {
method: 'POST',
path: '/generate-upload-url',
path: '/v1/generate-upload-url',
summary: 'Generate upload URL',
description: 'Used to upload anything from the client to S3 bucket',
},

View File

@@ -8,7 +8,7 @@ export const receiveMessage = publicProcedure
.meta({
openapi: {
method: 'POST',
path: '/workspaces/{workspaceId}/whatsapp/{credentialsId}/webhook',
path: '/v1/workspaces/{workspaceId}/whatsapp/{credentialsId}/webhook',
summary: 'Message webhook',
tags: ['WhatsApp'],
},

View File

@@ -7,7 +7,7 @@ export const subscribeWebhook = publicProcedure
.meta({
openapi: {
method: 'GET',
path: '/workspaces/{workspaceId}/whatsapp/{credentialsId}/webhook',
path: '/v1/workspaces/{workspaceId}/whatsapp/{credentialsId}/webhook',
summary: 'Subscribe webhook',
tags: ['WhatsApp'],
protect: true,

View File

@@ -0,0 +1,26 @@
import { sendMessageV1 } from '@/features/chat/api/legacy/sendMessageV1'
import { whatsAppRouter } from '@/features/whatsapp/api/router'
import { router } from './trpc'
import { updateTypebotInSession } from '@/features/chat/api/updateTypebotInSession'
import { getUploadUrl } from '@/features/fileUpload/api/deprecated/getUploadUrl'
import { generateUploadUrl } from '@/features/fileUpload/api/generateUploadUrl'
import { sendMessageV2 } from '@/features/chat/api/legacy/sendMessageV2'
import { continueChat } from '@/features/chat/api/continueChat'
import { saveClientLogs } from '@/features/chat/api/saveClientLogs'
import { startChat } from '@/features/chat/api/startChat'
import { startChatPreview } from '@/features/chat/api/startChatPreview'
export const appRouter = router({
sendMessageV1,
sendMessageV2,
startChat,
continueChat,
startChatPreview: startChatPreview,
getUploadUrl,
generateUploadUrl,
updateTypebotInSession,
whatsAppRouter,
saveClientLogs,
})
export type AppRouter = typeof appRouter

View File

@@ -3,6 +3,8 @@ import { inferAsyncReturnType } from '@trpc/server'
import * as trpcNext from '@trpc/server/adapters/next'
import { User } from '@typebot.io/prisma'
import { NextApiRequest } from 'next'
import { mockedUser } from '@typebot.io/lib/mockedUser'
import { env } from '@typebot.io/env'
export async function createContext(opts: trpcNext.CreateNextContextOptions) {
const user = await getAuthenticatedUser(opts.req)
@@ -15,6 +17,7 @@ export async function createContext(opts: trpcNext.CreateNextContextOptions) {
const getAuthenticatedUser = async (
req: NextApiRequest
): Promise<User | undefined> => {
if (env.NEXT_PUBLIC_E2E_TEST) return mockedUser
const bearerToken = extractBearerToken(req)
if (!bearerToken) return
return authenticateByToken(bearerToken)

View File

@@ -1,11 +1,11 @@
import { generateOpenApiDocument } from 'trpc-openapi'
import { writeFileSync } from 'fs'
import { appRouter } from './routers/appRouterV2'
import { appRouter } from './appRouter'
const openApiDocument = generateOpenApiDocument(appRouter, {
title: 'Chat API',
version: '2.0.0',
baseUrl: 'https://typebot.io/api/v2',
version: '3.0.0',
baseUrl: 'https://typebot.io/api',
docsUrl: 'https://docs.typebot.io/api',
})

View File

@@ -1,16 +0,0 @@
import { sendMessageV1 } from '@/features/chat/api/sendMessageV1'
import { whatsAppRouter } from '@/features/whatsapp/api/router'
import { router } from '../trpc'
import { updateTypebotInSession } from '@/features/chat/api/updateTypebotInSession'
import { getUploadUrl } from '@/features/fileUpload/api/deprecated/getUploadUrl'
import { generateUploadUrl } from '@/features/fileUpload/api/generateUploadUrl'
export const appRouter = router({
sendMessageV1,
getUploadUrl,
generateUploadUrl,
updateTypebotInSession,
whatsAppRouter,
})
export type AppRouter = typeof appRouter

View File

@@ -1,16 +0,0 @@
import { sendMessageV2 } from '@/features/chat/api/sendMessageV2'
import { whatsAppRouter } from '@/features/whatsapp/api/router'
import { router } from '../trpc'
import { updateTypebotInSession } from '@/features/chat/api/updateTypebotInSession'
import { getUploadUrl } from '@/features/fileUpload/api/deprecated/getUploadUrl'
import { generateUploadUrl } from '@/features/fileUpload/api/generateUploadUrl'
export const appRouter = router({
sendMessageV2,
getUploadUrl,
generateUploadUrl,
updateTypebotInSession,
whatsAppRouter,
})
export type AppRouter = typeof appRouter

View File

@@ -1,4 +1,4 @@
import { initTRPC } from '@trpc/server'
import { TRPCError, initTRPC } from '@trpc/server'
import { OpenApiMeta } from 'trpc-openapi'
import superjson from 'superjson'
import { Context } from './context'
@@ -8,13 +8,23 @@ const t = initTRPC.context<Context>().meta<OpenApiMeta>().create({
transformer: superjson,
})
export const router = t.router
const sentryMiddleware = t.middleware(
Sentry.Handlers.trpcMiddleware({
attachRpcInput: true,
})
)
const injectUser = t.middleware(({ next, ctx }) => {
export const publicProcedure = t.procedure.use(sentryMiddleware)
const isAuthed = t.middleware(({ next, ctx }) => {
if (!ctx.user?.id) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'You need to be authenticated to perform this action',
})
}
return next({
ctx: {
user: ctx.user,
@@ -22,10 +32,6 @@ const injectUser = t.middleware(({ next, ctx }) => {
})
})
const finalMiddleware = sentryMiddleware.unstable_pipe(injectUser)
export const middleware = t.middleware
export const router = t.router
export const publicProcedure = t.procedure.use(finalMiddleware)
export const authenticatedProcedure = t.procedure.use(
sentryMiddleware.unstable_pipe(isAuthed)
)

View File

@@ -1,4 +1,4 @@
import { appRouter } from '@/helpers/server/routers/appRouterV2'
import { appRouter } from '@/helpers/server/appRouter'
import * as Sentry from '@sentry/nextjs'
import { createOpenApiNextHandler } from 'trpc-openapi'
import cors from 'nextjs-cors'

View File

@@ -1,23 +0,0 @@
import { appRouter } from '@/helpers/server/routers/appRouterV1'
import * as Sentry from '@sentry/nextjs'
import { createOpenApiNextHandler } from 'trpc-openapi'
import cors from 'nextjs-cors'
import { NextApiRequest, NextApiResponse } from 'next'
import { createContext } from '@/helpers/server/context'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
await cors(req, res)
return createOpenApiNextHandler({
router: appRouter,
createContext,
onError({ error }) {
if (error.code === 'INTERNAL_SERVER_ERROR') {
Sentry.captureException(error)
console.error('Something went wrong', error)
}
},
})(req, res)
}
export default handler

View File

@@ -2,7 +2,6 @@ import { getTestAsset } from '@/test/utils/playwright'
import test, { expect } from '@playwright/test'
import { createId } from '@paralleldrive/cuid2'
import prisma from '@typebot.io/lib/prisma'
import { SendMessageInput } from '@typebot.io/schemas'
import {
createWebhook,
deleteTypebots,
@@ -10,6 +9,7 @@ import {
importTypebotInDatabase,
} from '@typebot.io/lib/playwright/databaseActions'
import { HttpMethod } from '@typebot.io/schemas/features/blocks/integrations/webhook/constants'
import { StartChatInput, StartPreviewChatInput } from '@typebot.io/schemas'
test.afterEach(async () => {
await deleteWebhooks(['chat-webhook-id'])
@@ -40,17 +40,18 @@ test('API chat execution should work on preview bot', async ({ request }) => {
url: 'https://api.chucknorris.io/jokes/random',
})
await test.step('Start the chat', async () => {
let chatSessionId: string
await test.step('Can start and continue chat', async () => {
const { sessionId, messages, input, resultId } = await (
await request.post(`/api/v2/sendMessage`, {
await request.post(`/api/v1/typebots/${typebotId}/preview/startChat`, {
data: {
startParams: {
typebot: typebotId,
isPreview: true,
},
} satisfies SendMessageInput,
isOnlyRegistering: false,
isStreamEnabled: false,
} satisfies Omit<StartPreviewChatInput, 'typebotId'>,
})
).json()
chatSessionId = sessionId
expect(resultId).toBeUndefined()
expect(sessionId).toBeDefined()
expect(messages[0].content.richText).toStrictEqual([
@@ -61,6 +62,38 @@ test('API chat execution should work on preview bot', async ({ request }) => {
])
expect(input.type).toBe('text input')
})
await test.step('Can answer Name question', async () => {
const { messages, input } = await (
await request.post(`/api/v1/sessions/${chatSessionId}/continueChat`, {
data: {
message: 'John',
},
})
).json()
expect(messages[0].content.richText).toStrictEqual([
{
children: [
{ text: 'Nice to meet you ' },
{
type: 'inline-variable',
children: [
{
type: 'p',
children: [
{
text: 'John',
},
],
},
],
},
],
type: 'p',
},
])
expect(input.type).toBe('number input')
})
})
test('API chat execution should work on published bot', async ({ request }) => {
@@ -83,12 +116,11 @@ test('API chat execution should work on published bot', async ({ request }) => {
await test.step('Start the chat', async () => {
const { sessionId, messages, input, resultId } = await (
await request.post(`/api/v2/sendMessage`, {
await request.post(`/api/v1/typebots/${publicId}/startChat`, {
data: {
startParams: {
typebot: publicId,
},
} satisfies SendMessageInput,
isOnlyRegistering: false,
isStreamEnabled: false,
} satisfies Omit<StartChatInput, 'publicId'>,
})
).json()
chatSessionId = sessionId
@@ -111,8 +143,8 @@ test('API chat execution should work on published bot', async ({ request }) => {
await test.step('Answer Name question', async () => {
const { messages, input } = await (
await request.post(`/api/v2/sendMessage`, {
data: { message: 'John', sessionId: chatSessionId },
await request.post(`/api/v1/sessions/${chatSessionId}/continueChat`, {
data: { message: 'John' },
})
).json()
expect(messages[0].content.richText).toStrictEqual([
@@ -142,8 +174,8 @@ test('API chat execution should work on published bot', async ({ request }) => {
await test.step('Answer Age question', async () => {
const { messages, input } = await (
await request.post(`/api/v2/sendMessage`, {
data: { message: '24', sessionId: chatSessionId },
await request.post(`/api/v1/sessions/${chatSessionId}/continueChat`, {
data: { message: '24' },
})
).json()
expect(messages[0].content.richText).toStrictEqual([
@@ -181,8 +213,8 @@ test('API chat execution should work on published bot', async ({ request }) => {
await test.step('Answer Rating question', async () => {
const { messages, input } = await (
await request.post(`/api/v2/sendMessage`, {
data: { message: '8', sessionId: chatSessionId },
await request.post(`/api/v1/sessions/${chatSessionId}/continueChat`, {
data: { message: '8' },
})
).json()
expect(messages[0].content.richText).toStrictEqual([
@@ -196,8 +228,8 @@ test('API chat execution should work on published bot', async ({ request }) => {
await test.step('Answer Email question with wrong input', async () => {
const { messages, input } = await (
await request.post(`/api/v2/sendMessage`, {
data: { message: 'invalid email', sessionId: chatSessionId },
await request.post(`/api/v1/sessions/${chatSessionId}/continueChat`, {
data: { message: 'invalid email' },
})
).json()
expect(messages[0].content.richText).toStrictEqual([
@@ -215,8 +247,8 @@ test('API chat execution should work on published bot', async ({ request }) => {
await test.step('Answer Email question with valid input', async () => {
const { messages, input } = await (
await request.post(`/api/v2/sendMessage`, {
data: { message: 'typebot@email.com', sessionId: chatSessionId },
await request.post(`/api/v1/sessions/${chatSessionId}/continueChat`, {
data: { message: 'typebot@email.com' },
})
).json()
expect(messages.length).toBe(0)
@@ -225,8 +257,8 @@ test('API chat execution should work on published bot', async ({ request }) => {
await test.step('Answer URL question', async () => {
const { messages, input } = await (
await request.post(`/api/v2/sendMessage`, {
data: { message: 'https://typebot.io', sessionId: chatSessionId },
await request.post(`/api/v1/sessions/${chatSessionId}/continueChat`, {
data: { message: 'https://typebot.io' },
})
).json()
expect(messages.length).toBe(0)
@@ -235,8 +267,8 @@ test('API chat execution should work on published bot', async ({ request }) => {
await test.step('Answer Buttons question with invalid choice', async () => {
const { messages } = await (
await request.post(`/api/v2/sendMessage`, {
data: { message: 'Yes', sessionId: chatSessionId },
await request.post(`/api/v1/sessions/${chatSessionId}/continueChat`, {
data: { message: 'Yes' },
})
).json()
expect(messages[0].content.richText).toStrictEqual([
@@ -263,14 +295,14 @@ test('API chat execution should work on published bot', async ({ request }) => {
})
await test.step('Starting with a message when typebot starts with input should proceed', async () => {
const { messages } = await (
await request.post(`/api/v2/sendMessage`, {
data: {
message: 'Hey',
startParams: {
typebot: 'starting-with-input-public',
},
} satisfies SendMessageInput,
})
await request.post(
`/api/v1/typebots/starting-with-input-public/startChat`,
{
data: {
message: 'Hey',
} satisfies Omit<StartChatInput, 'publicId'>,
}
)
).json()
expect(messages[0].content.richText).toStrictEqual([
{

View File

@@ -30,7 +30,7 @@ test('Result should be overwritten on page refresh', async ({ page }) => {
const [, response] = await Promise.all([
page.goto(`/${typebotId}-public`),
page.waitForResponse(/sendMessage/),
page.waitForResponse(/startChat/),
])
const { resultId } = await response.json()
expect(resultId).toBeDefined()
@@ -38,7 +38,7 @@ test('Result should be overwritten on page refresh', async ({ page }) => {
const [, secondResponse] = await Promise.all([
page.reload(),
page.waitForResponse(/sendMessage/),
page.waitForResponse(/startChat/),
])
const { resultId: secondResultId } = await secondResponse.json()
expect(secondResultId).toBe(resultId)
@@ -57,7 +57,7 @@ test.describe('Create result on page refresh enabled', () => {
])
const [, response] = await Promise.all([
page.goto(`/${typebotId}-public`),
page.waitForResponse(/sendMessage/),
page.waitForResponse(/startChat/),
])
const { resultId } = await response.json()
expect(resultId).toBeDefined()
@@ -65,7 +65,7 @@ test.describe('Create result on page refresh enabled', () => {
await expect(page.getByRole('textbox')).toBeVisible()
const [, secondResponse] = await Promise.all([
page.reload(),
page.waitForResponse(/sendMessage/),
page.waitForResponse(/startChat/),
])
const { resultId: secondResultId } = await secondResponse.json()
expect(secondResultId).not.toBe(resultId)

View File

@@ -1,11 +1,11 @@
import { generateOpenApiDocument } from 'trpc-openapi'
import { writeFileSync } from 'fs'
import { appRouter } from '@/helpers/server/routers/appRouterV2'
import { appRouter } from '@/helpers/server/appRouter'
const openApiDocument = generateOpenApiDocument(appRouter, {
title: 'Chat API',
version: '2.0.0',
baseUrl: 'https://typebot.io/api/v2',
version: '3.0.0',
baseUrl: 'https://typebot.io/api',
docsUrl: 'https://docs.typebot.io/api',
})