diff --git a/.npmrc b/.npmrc index 39c9b231b3..f0c0c3dfe4 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1 @@ -public-hoist-pattern[]=*prisma* \ No newline at end of file +public-hoist-pattern[]=*prisma* diff --git a/apps/builder/.prettierignore b/apps/builder/.prettierignore index e6620fe85d..561504a014 100644 --- a/apps/builder/.prettierignore +++ b/apps/builder/.prettierignore @@ -1,3 +1,3 @@ emojiList.json iconNames.ts -reporters \ No newline at end of file +reporters diff --git a/apps/builder/next.config.mjs b/apps/builder/next.config.mjs index 6384faf612..479b454820 100644 --- a/apps/builder/next.config.mjs +++ b/apps/builder/next.config.mjs @@ -21,6 +21,12 @@ const injectViewerUrlIfVercelPreview = (val) => { process.env.VERCEL_BUILDER_PROJECT_NAME, process.env.NEXT_PUBLIC_VERCEL_VIEWER_PROJECT_NAME ) + if (process.env.NEXT_PUBLIC_CHAT_API_URL.includes('{{pr_id}}')) + process.env.NEXT_PUBLIC_CHAT_API_URL = + process.env.NEXT_PUBLIC_CHAT_API_URL.replace( + '{{pr_id}}', + process.env.VERCEL_GIT_PULL_REQUEST_ID + ) } injectViewerUrlIfVercelPreview(process.env.NEXT_PUBLIC_VIEWER_URL) diff --git a/apps/builder/package.json b/apps/builder/package.json index 537beb159e..5ea16f24c1 100644 --- a/apps/builder/package.json +++ b/apps/builder/package.json @@ -75,7 +75,7 @@ "next": "14.1.0", "next-auth": "4.22.1", "nextjs-cors": "2.1.2", - "nodemailer": "6.9.3", + "nodemailer": "6.9.8", "nprogress": "0.2.0", "openai": "4.28.4", "papaparse": "5.4.1", @@ -108,7 +108,7 @@ "@types/jsonwebtoken": "9.0.2", "@types/micro-cors": "0.1.3", "@types/node": "20.4.2", - "@types/nodemailer": "6.4.8", + "@types/nodemailer": "6.4.14", "@types/nprogress": "0.2.0", "@types/papaparse": "5.3.7", "@types/prettier": "2.7.3", diff --git a/apps/builder/src/features/whatsapp/startWhatsAppPreview.ts b/apps/builder/src/features/whatsapp/startWhatsAppPreview.ts index f7fbf377e9..cf19ed2eb0 100644 --- a/apps/builder/src/features/whatsapp/startWhatsAppPreview.ts +++ b/apps/builder/src/features/whatsapp/startWhatsAppPreview.ts @@ -114,6 +114,7 @@ export const startWhatsAppPreview = authenticatedProcedure typebotId, startFrom, userId: user.id, + isStreamEnabled: false, }, initialSessionState: { whatsApp: (existingSession?.state as SessionState | undefined) diff --git a/apps/chat-api/package.json b/apps/chat-api/package.json new file mode 100644 index 0000000000..9e34fa4931 --- /dev/null +++ b/apps/chat-api/package.json @@ -0,0 +1,36 @@ +{ + "name": "chat-api", + "version": "1.0.0", + "license": "AGPL-3.0-or-later", + "private": true, + "scripts": { + "dev": "dotenv -e ./.env -e ../../.env -- bun --hot src/index.ts", + "build": "dotenv -e ./.env -e ../../.env -- bun build --target=bun ./src/index.ts --outdir ./dist", + "start": "bun src/index.ts" + }, + "dependencies": { + "@hono/prometheus": "1.0.0", + "@hono/sentry": "1.0.1", + "@hono/typebox-validator": "0.2.2", + "@sinclair/typebox": "0.32.5", + "@trpc/server": "10.40.0", + "@typebot.io/bot-engine": "workspace:*", + "@typebot.io/env": "workspace:*", + "@typebot.io/forge": "workspace:*", + "@typebot.io/forge-repository": "workspace:*", + "@typebot.io/lib": "workspace:*", + "@typebot.io/prisma": "workspace:*", + "@typebot.io/schemas": "workspace:*", + "@typebot.io/variables": "workspace:*", + "ai": "3.0.12", + "hono": "4.0.5", + "openai": "4.28.4", + "prom-client": "15.1.0" + }, + "devDependencies": { + "dotenv-cli": "7.2.1", + "@typebot.io/tsconfig": "workspace:*", + "@types/react": "18.2.15", + "react": "18.2.0" + } +} diff --git a/apps/chat-api/src/auth.ts b/apps/chat-api/src/auth.ts new file mode 100644 index 0000000000..d71e112c08 --- /dev/null +++ b/apps/chat-api/src/auth.ts @@ -0,0 +1,30 @@ +import { env } from '@typebot.io/env' +import { mockedUser } from '@typebot.io/lib/mockedUser' +import prisma from '@typebot.io/lib/prisma' + +export const getAuthenticatedUserId = async ( + authorizationHeaderValue: string | undefined +): Promise => { + if (env.NEXT_PUBLIC_E2E_TEST) return mockedUser.id + const bearerToken = extractBearerToken(authorizationHeaderValue) + if (!bearerToken) return + return authenticateByToken(bearerToken) +} + +const authenticateByToken = async ( + token: string +): Promise => { + if (typeof window !== 'undefined') return + const apiToken = await prisma.apiToken.findFirst({ + where: { + token, + }, + select: { + ownerId: true, + }, + }) + return apiToken?.ownerId +} + +const extractBearerToken = (authorizationHeaderValue: string | undefined) => + authorizationHeaderValue?.slice(7) diff --git a/apps/chat-api/src/index.ts b/apps/chat-api/src/index.ts new file mode 100644 index 0000000000..de37e8b057 --- /dev/null +++ b/apps/chat-api/src/index.ts @@ -0,0 +1,30 @@ +import { Hono } from 'hono' +import { webRuntime } from './runtimes/web' +import { whatsAppRuntime } from './runtimes/whatsapp' +import { prometheus } from '@hono/prometheus' +import { sentry } from '@hono/sentry' +import { env } from '@typebot.io/env' + +const app = new Hono() + +app.use( + '*', + sentry({ + environment: env.NODE_ENV, + dsn: env.NEXT_PUBLIC_SENTRY_DSN, + release: process.env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA + '-chat-api', + }) +) + +const { printMetrics, registerMetrics } = prometheus() +app.use('*', registerMetrics) +app.get('/metrics', printMetrics) + +app.get('/ping', (c) => c.json({ status: 'ok' }, 200)) +app.route('/', webRuntime) +app.route('/', whatsAppRuntime) + +export default { + port: process.env.PORT ?? 3002, + fetch: app.fetch, +} diff --git a/apps/chat-api/src/runtimes/web.ts b/apps/chat-api/src/runtimes/web.ts new file mode 100644 index 0000000000..d0ec6c6410 --- /dev/null +++ b/apps/chat-api/src/runtimes/web.ts @@ -0,0 +1,132 @@ +import { startChat } from '@typebot.io/bot-engine/apiHandlers/startChat' +import { continueChat } from '@typebot.io/bot-engine/apiHandlers/continueChat' +import { startChatPreview } from '@typebot.io/bot-engine/apiHandlers/startChatPreview' +import { getMessageStream } from '@typebot.io/bot-engine/apiHandlers/getMessageStream' +import { Hono } from 'hono' +import { cors } from 'hono/cors' +import { tbValidator } from '@hono/typebox-validator' +import { Type as t } from '@sinclair/typebox' +import { getAuthenticatedUserId } from '../auth' + +export const webRuntime = new Hono() + +webRuntime.use('*', cors()) + +webRuntime.post( + '/api/v1/typebots/:publicId/startChat', + tbValidator( + 'json', + t.Object({ + message: t.Optional(t.String()), + isStreamEnabled: t.Optional(t.Boolean()), + resultId: t.Optional(t.String()), + isOnlyRegistering: t.Optional(t.Boolean()), + prefilledVariables: t.Optional(t.Record(t.String(), t.Unknown())), + }), + (result, c) => { + if (!result.success) return c.json({ message: 'Invalid input' }, 400) + } + ), + async (c) => { + const data = c.req.valid('json') + const { corsOrigin, ...response } = await startChat({ + ...data, + publicId: c.req.param('publicId'), + isStreamEnabled: data.isStreamEnabled ?? true, + isOnlyRegistering: data.isOnlyRegistering ?? false, + origin: c.req.header('origin'), + }) + if (corsOrigin) c.res.headers.set('Access-Control-Allow-Origin', corsOrigin) + return c.json(response) + } +) + +webRuntime.post( + '/api/v1/typebots/:id/preview/startChat', + tbValidator( + 'json', + t.Object({ + message: t.Optional(t.String()), + isStreamEnabled: t.Optional(t.Boolean()), + resultId: t.Optional(t.String()), + isOnlyRegistering: t.Optional(t.Boolean()), + prefilledVariables: t.Optional(t.Record(t.String(), t.Unknown())), + startFrom: t.Optional( + t.Union([ + t.Object({ + type: t.Literal('group'), + groupId: t.String(), + }), + t.Object({ + type: t.Literal('event'), + eventId: t.String(), + }), + ]) + ), + typebot: t.Optional(t.Any()), + }), + (result, c) => { + if (!result.success) return c.json({ message: 'Invalid input' }, 400) + } + ), + async (c) => { + const data = c.req.valid('json') + const userId = !data.typebot + ? await getAuthenticatedUserId(c.req.header('Authorization')) + : undefined + return c.json( + await startChatPreview({ + ...data, + typebotId: c.req.param('id'), + userId, + isStreamEnabled: data.isStreamEnabled ?? true, + isOnlyRegistering: data.isOnlyRegistering ?? false, + }) + ) + } +) + +webRuntime.post( + '/api/v1/sessions/:sessionId/continueChat', + tbValidator( + 'json', + t.Object({ + message: t.Optional(t.String()), + }), + (result, c) => { + if (!result.success) return c.json({ message: 'Invalid input' }, 400) + } + ), + async (c) => { + const data = c.req.valid('json') + const { corsOrigin, ...response } = await continueChat({ + ...data, + sessionId: c.req.param('sessionId'), + origin: c.req.header('origin'), + }) + if (corsOrigin) c.res.headers.set('Access-Control-Allow-Origin', corsOrigin) + return c.json(response) + } +) + +webRuntime.post( + '/api/v1/sessions/:sessionId/streamMessage', + tbValidator( + 'json', + t.Object({ + messages: t.Optional(t.Array(t.Any())), + }), + (result, c) => { + if (!result.success) return c.json({ message: 'Invalid input' }, 400) + } + ), + async (c) => { + const data = c.req.valid('json') + const { stream, status, message } = await getMessageStream({ + sessionId: c.req.param('sessionId'), + messages: data.messages, + }) + if (!stream) return c.json({ message }, (status ?? 400) as ResponseInit) + return new Response(stream) + } +) diff --git a/apps/chat-api/src/runtimes/whatsapp.ts b/apps/chat-api/src/runtimes/whatsapp.ts new file mode 100644 index 0000000000..d0d764abb7 --- /dev/null +++ b/apps/chat-api/src/runtimes/whatsapp.ts @@ -0,0 +1,51 @@ +import { receiveMessage } from '@typebot.io/bot-engine/apiHandlers/receiveMessage' +import { receiveMessagePreview } from '@typebot.io/bot-engine/apiHandlers/receiveMessagePreview' +import { tbValidator } from '@hono/typebox-validator' +import { Hono } from 'hono' +import { Type as t } from '@sinclair/typebox' + +export const whatsAppRuntime = new Hono() + +whatsAppRuntime.post( + '/api/v1/workspaces/:workspaceId/whatsapp/:credentialsId/webhook', + tbValidator( + 'json', + t.Object({ + object: t.String(), + entry: t.Any(), + }), + (result, c) => { + if (!result.success) return c.json({ message: 'Invalid input' }, 400) + } + ), + async (c) => { + const data = c.req.valid('json') + receiveMessage({ + workspaceId: c.req.param('workspaceId'), + credentialsId: c.req.param('credentialsId'), + ...data, + }) + return c.json({ message: 'Webhook received' }, 200) + } +) + +whatsAppRuntime.post( + '/api/v1/whatsapp/preview/webhook', + tbValidator( + 'json', + t.Object({ + object: t.String(), + entry: t.Any(), + }), + (result, c) => { + if (!result.success) return c.json({ message: 'Invalid input' }, 400) + } + ), + async (c) => { + const data = c.req.valid('json') + receiveMessagePreview({ + ...data, + }) + return c.json({ message: 'Webhook received' }, 200) + } +) diff --git a/apps/chat-api/tsconfig.json b/apps/chat-api/tsconfig.json new file mode 100644 index 0000000000..8d02ef5be5 --- /dev/null +++ b/apps/chat-api/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@typebot.io/tsconfig/base.json", + "include": ["src/**/*.ts"], + "exclude": ["node_modules"], + "compilerOptions": { + "jsx": "react" + } +} diff --git a/apps/docs/openapi/viewer.json b/apps/docs/openapi/viewer.json index 72d082c59a..637945b567 100644 --- a/apps/docs/openapi/viewer.json +++ b/apps/docs/openapi/viewer.json @@ -1772,14 +1772,16 @@ "type": "object", "properties": { "isStreamEnabled": { - "type": "boolean" + "type": "boolean", + "default": false }, "message": { "type": "string" }, "isOnlyRegistering": { "type": "boolean", - "description": "If set to `true`, it will only register the session and not start the bot. This is used for 3rd party chat platforms as it can require a session to be registered before sending the first message." + "description": "If set to `true`, it will only register the session and not start the bot. This is used for 3rd party chat platforms as it can require a session to be registered before sending the first message.", + "default": false }, "typebot": { "oneOf": [ diff --git a/apps/landing-page/package.json b/apps/landing-page/package.json index ba0b7a0291..33a60ab630 100644 --- a/apps/landing-page/package.json +++ b/apps/landing-page/package.json @@ -2,7 +2,7 @@ "name": "landing-page", "version": "1.0.0", "scripts": { - "dev": "dotenv -e ./.env -e ../../.env -- next dev -p 3002", + "dev": "dotenv -e ./.env -e ../../.env -- next dev -p 3003", "start": "dotenv -e ./.env -e ../../.env -- next start", "build": "dotenv -e ./.env -e ../../.env -- next build", "lint": "next lint", diff --git a/apps/viewer/next.config.mjs b/apps/viewer/next.config.mjs index f211af362d..f0498d2e25 100644 --- a/apps/viewer/next.config.mjs +++ b/apps/viewer/next.config.mjs @@ -17,6 +17,12 @@ const injectViewerUrlIfVercelPreview = (val) => { ) return process.env.NEXT_PUBLIC_VIEWER_URL = `https://${process.env.VERCEL_BRANCH_URL}` + if (process.env.NEXT_PUBLIC_CHAT_API_URL.includes('{{pr_id}}')) + process.env.NEXT_PUBLIC_CHAT_API_URL = + process.env.NEXT_PUBLIC_CHAT_API_URL.replace( + '{{pr_id}}', + process.env.VERCEL_GIT_PULL_REQUEST_ID + ) } injectViewerUrlIfVercelPreview(process.env.NEXT_PUBLIC_VIEWER_URL) diff --git a/apps/viewer/package.json b/apps/viewer/package.json index 33af09e510..161c5c1fca 100644 --- a/apps/viewer/package.json +++ b/apps/viewer/package.json @@ -28,7 +28,7 @@ "got": "12.6.0", "next": "14.1.0", "nextjs-cors": "2.1.2", - "nodemailer": "6.9.3", + "nodemailer": "6.9.8", "openai": "4.28.4", "qs": "6.11.2", "react": "18.2.0", @@ -50,7 +50,7 @@ "@typebot.io/variables": "workspace:*", "@types/cors": "2.8.13", "@types/node": "20.4.2", - "@types/nodemailer": "6.4.8", + "@types/nodemailer": "6.4.14", "@types/papaparse": "5.3.7", "@types/qs": "6.9.7", "@types/react": "18.2.15", diff --git a/apps/viewer/src/app/api/v1/sessions/[sessionId]/streamMessage/route.ts b/apps/viewer/src/app/api/v1/sessions/[sessionId]/streamMessage/route.ts new file mode 100644 index 0000000000..4f4fa49ff8 --- /dev/null +++ b/apps/viewer/src/app/api/v1/sessions/[sessionId]/streamMessage/route.ts @@ -0,0 +1,45 @@ +import { getMessageStream } from '@typebot.io/bot-engine/apiHandlers/getMessageStream' +import { StreamingTextResponse } from 'ai' +import { NextResponse } from 'next/server' + +export const dynamic = 'force-dynamic' + +const responseHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Expose-Headers': 'Content-Length, X-JSON', + 'Access-Control-Allow-Headers': '*', +} + +export async function OPTIONS() { + return new Response('ok', { + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST', + 'Access-Control-Expose-Headers': 'Content-Length, X-JSON', + 'Access-Control-Allow-Headers': '*', + }, + }) +} + +export async function POST( + req: Request, + { params }: { params: { sessionId: string } } +) { + if (process.env.VERCEL_ENV) + return NextResponse.json( + { message: "Can't get streaming if hosted on Vercel" }, + { status: 400, headers: responseHeaders } + ) + const messages = + typeof req.body === 'string' ? JSON.parse(req.body) : req.body + const { stream, status, message } = await getMessageStream({ + sessionId: params.sessionId, + messages, + }) + if (!stream) + return NextResponse.json({ message }, { status, headers: responseHeaders }) + return new StreamingTextResponse(stream, { + headers: responseHeaders, + }) +} diff --git a/apps/viewer/src/features/chat/api/continueChat.ts b/apps/viewer/src/features/chat/api/continueChat.ts index a6f2ffd097..e628382c2a 100644 --- a/apps/viewer/src/features/chat/api/continueChat.ts +++ b/apps/viewer/src/features/chat/api/continueChat.ts @@ -1,14 +1,7 @@ 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, isNotDefined } from '@typebot.io/lib/utils' import { z } from 'zod' -import { filterPotentiallySensitiveLogs } from '@typebot.io/bot-engine/logs/filterPotentiallySensitiveLogs' -import { computeCurrentProgress } from '@typebot.io/bot-engine/computeCurrentProgress' +import { continueChat as continueChatFn } from '@typebot.io/bot-engine/apiHandlers/continueChat' export const continueChat = publicProcedure .meta({ @@ -29,92 +22,12 @@ export const continueChat = publicProcedure }) ) .output(continueChatResponseSchema) - .mutation(async ({ input: { sessionId, message }, ctx: { res, origin } }) => { - 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.', - }) - - if ( - session?.state.allowedOrigins && - session.state.allowedOrigins.length > 0 - ) { - if (origin && session.state.allowedOrigins.includes(origin)) - res.setHeader('Access-Control-Allow-Origin', origin) - else - res.setHeader( - 'Access-Control-Allow-Origin', - session.state.allowedOrigins[0] - ) - } - - const { - messages, - input, - clientSideActions, - newSessionState, - logs, - lastMessageNewFormat, - visitedEdges, - } = await continueBotFlow(message, { - version: 2, - state: session.state, - startTime: Date.now(), + .mutation(async ({ input: { sessionId, message }, ctx: { origin, res } }) => { + const { corsOrigin, ...response } = await continueChatFn({ + origin, + sessionId, + message, }) - - if (newSessionState) - await saveStateToDatabase({ - session: { - id: session.id, - state: newSessionState, - }, - input, - logs, - clientSideActions, - visitedEdges, - hasCustomEmbedBubble: messages.some( - (message) => message.type === 'custom-embed' - ), - }) - - const isPreview = isNotDefined(session.state.typebotsQueue[0].resultId) - - const isEnded = - newSessionState.progressMetadata && - !input?.id && - (clientSideActions?.filter((c) => c.expectsDedicatedReply).length ?? - 0) === 0 - - return { - messages, - input, - clientSideActions, - dynamicTheme: parseDynamicTheme(newSessionState), - logs: isPreview ? logs : logs?.filter(filterPotentiallySensitiveLogs), - lastMessageNewFormat, - progress: newSessionState.progressMetadata - ? isEnded - ? 100 - : computeCurrentProgress({ - typebotsQueue: newSessionState.typebotsQueue, - progressMetadata: newSessionState.progressMetadata, - currentInputBlockId: input?.id, - }) - : undefined, - } + if (corsOrigin) res.setHeader('Access-Control-Allow-Origin', corsOrigin) + return response }) diff --git a/apps/viewer/src/features/chat/api/saveClientLogs.ts b/apps/viewer/src/features/chat/api/saveClientLogs.ts index af227d1cf4..1f40a4c685 100644 --- a/apps/viewer/src/features/chat/api/saveClientLogs.ts +++ b/apps/viewer/src/features/chat/api/saveClientLogs.ts @@ -1,11 +1,7 @@ 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' +import { saveClientLogs as saveClientLogsFn } from '@typebot.io/bot-engine/apiHandlers/saveClientLogs' export const saveClientLogs = publicProcedure .meta({ @@ -22,42 +18,6 @@ export const saveClientLogs = publicProcedure }) ) .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.', - }) - } - }) + .mutation(({ input: { sessionId, clientLogs } }) => + saveClientLogsFn({ sessionId, clientLogs }) + ) diff --git a/apps/viewer/src/features/chat/api/startChat.ts b/apps/viewer/src/features/chat/api/startChat.ts index 01fa45725b..ff839dac38 100644 --- a/apps/viewer/src/features/chat/api/startChat.ts +++ b/apps/viewer/src/features/chat/api/startChat.ts @@ -3,11 +3,7 @@ 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' -import { filterPotentiallySensitiveLogs } from '@typebot.io/bot-engine/logs/filterPotentiallySensitiveLogs' -import { computeCurrentProgress } from '@typebot.io/bot-engine/computeCurrentProgress' +import { startChat as startChatFn } from '@typebot.io/bot-engine/apiHandlers/startChat' export const startChat = publicProcedure .meta({ @@ -19,99 +15,11 @@ export const startChat = publicProcedure }) .input(startChatInputSchema) .output(startChatResponseSchema) - .mutation( - async ({ - input: { - message, - isOnlyRegistering, - publicId, - isStreamEnabled, - prefilledVariables, - resultId: startResultId, - }, - ctx: { origin, res }, - }) => { - const { - typebot, - messages, - input, - resultId, - dynamicTheme, - logs, - clientSideActions, - newSessionState, - visitedEdges, - } = await startSession({ - version: 2, - startParams: { - type: 'live', - isOnlyRegistering, - isStreamEnabled, - publicId, - prefilledVariables, - resultId: startResultId, - }, - message, - }) - - if ( - newSessionState.allowedOrigins && - newSessionState.allowedOrigins.length > 0 - ) { - if (origin && newSessionState.allowedOrigins.includes(origin)) - res.setHeader('Access-Control-Allow-Origin', origin) - else - res.setHeader( - 'Access-Control-Allow-Origin', - newSessionState.allowedOrigins[0] - ) - } - - const session = isOnlyRegistering - ? await restartSession({ - state: newSessionState, - }) - : await saveStateToDatabase({ - session: { - state: newSessionState, - }, - input, - logs, - clientSideActions, - visitedEdges, - hasCustomEmbedBubble: messages.some( - (message) => message.type === 'custom-embed' - ), - }) - - const isEnded = - newSessionState.progressMetadata && - !input?.id && - (clientSideActions?.filter((c) => c.expectsDedicatedReply).length ?? - 0) === 0 - - return { - sessionId: session.id, - typebot: { - id: typebot.id, - theme: typebot.theme, - settings: typebot.settings, - }, - messages, - input, - resultId, - dynamicTheme, - logs: logs?.filter(filterPotentiallySensitiveLogs), - clientSideActions, - progress: newSessionState.progressMetadata - ? isEnded - ? 100 - : computeCurrentProgress({ - typebotsQueue: newSessionState.typebotsQueue, - progressMetadata: newSessionState.progressMetadata, - currentInputBlockId: input?.id, - }) - : undefined, - } - } - ) + .mutation(async ({ input, ctx: { origin, res } }) => { + const { corsOrigin, ...response } = await startChatFn({ + ...input, + origin, + }) + if (corsOrigin) res.setHeader('Access-Control-Allow-Origin', corsOrigin) + return response + }) diff --git a/apps/viewer/src/features/chat/api/startChatPreview.ts b/apps/viewer/src/features/chat/api/startChatPreview.ts index 4d473af74c..991d5c42aa 100644 --- a/apps/viewer/src/features/chat/api/startChatPreview.ts +++ b/apps/viewer/src/features/chat/api/startChatPreview.ts @@ -2,11 +2,8 @@ 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' import { publicProcedure } from '@/helpers/server/trpc' -import { computeCurrentProgress } from '@typebot.io/bot-engine/computeCurrentProgress' +import { startChatPreview as startChatPreviewFn } from '@typebot.io/bot-engine/apiHandlers/startChatPreview' export const startChatPreview = publicProcedure .meta({ @@ -32,75 +29,15 @@ export const startChatPreview = publicProcedure prefilledVariables, }, 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, - prefilledVariables, - }, + }) => + startChatPreviewFn({ message, + isOnlyRegistering, + isStreamEnabled, + startFrom, + typebotId, + typebot: startTypebot, + userId: user?.id, + prefilledVariables, }) - - const session = isOnlyRegistering - ? await restartSession({ - state: newSessionState, - }) - : await saveStateToDatabase({ - session: { - state: newSessionState, - }, - input, - logs, - clientSideActions, - visitedEdges, - hasCustomEmbedBubble: messages.some( - (message) => message.type === 'custom-embed' - ), - }) - - const isEnded = - newSessionState.progressMetadata && - !input?.id && - (clientSideActions?.filter((c) => c.expectsDedicatedReply).length ?? - 0) === 0 - - return { - sessionId: session.id, - typebot: { - id: typebot.id, - theme: typebot.theme, - settings: typebot.settings, - }, - messages, - input, - dynamicTheme, - logs, - clientSideActions, - progress: newSessionState.progressMetadata - ? isEnded - ? 100 - : computeCurrentProgress({ - typebotsQueue: newSessionState.typebotsQueue, - progressMetadata: newSessionState.progressMetadata, - currentInputBlockId: input?.id, - }) - : undefined, - } - } ) diff --git a/apps/viewer/src/features/chat/api/updateTypebotInSession.ts b/apps/viewer/src/features/chat/api/updateTypebotInSession.ts index a262950d10..425476b312 100644 --- a/apps/viewer/src/features/chat/api/updateTypebotInSession.ts +++ b/apps/viewer/src/features/chat/api/updateTypebotInSession.ts @@ -1,14 +1,6 @@ import { publicProcedure } from '@/helpers/server/trpc' -import { TRPCError } from '@trpc/server' import { z } from 'zod' -import { getSession } from '@typebot.io/bot-engine/queries/getSession' -import { - PublicTypebot, - SessionState, - Typebot, - Variable, -} from '@typebot.io/schemas' -import prisma from '@typebot.io/lib/prisma' +import { updateTypebotInSession as updateTypebotInSessionFn } from '@typebot.io/bot-engine/apiHandlers/updateTypebotInSession' export const updateTypebotInSession = publicProcedure .meta({ @@ -27,85 +19,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' }) - - const publicTypebot = (await prisma.publicTypebot.findFirst({ - where: { - typebot: { - id: session.state.typebotsQueue[0].typebot.id, - OR: [ - { - workspace: { - members: { - some: { userId: user.id, role: { in: ['ADMIN', 'MEMBER'] } }, - }, - }, - }, - { - collaborators: { - some: { userId: user.id, type: { in: ['WRITE'] } }, - }, - }, - ], - }, - }, - select: { - edges: true, - groups: true, - variables: true, - }, - })) as Pick | null - - if (!publicTypebot) - throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Unauthorized' }) - - const newSessionState = updateSessionState(session.state, publicTypebot) - - await prisma.chatSession.updateMany({ - where: { id: session.id }, - data: { state: newSessionState }, - }) - - return { message: 'success' } - }) - -const updateSessionState = ( - currentState: SessionState, - newTypebot: Pick -): SessionState => ({ - ...currentState, - typebotsQueue: currentState.typebotsQueue.map((typebotInQueue, index) => - index === 0 - ? { - ...typebotInQueue, - typebot: { - ...typebotInQueue.typebot, - edges: newTypebot.edges, - groups: newTypebot.groups, - variables: updateVariablesInSession( - typebotInQueue.typebot.variables, - newTypebot.variables - ), - }, - } - : typebotInQueue - ) as SessionState['typebotsQueue'], -}) - -const updateVariablesInSession = ( - currentVariables: Variable[], - newVariables: Typebot['variables'] -): Variable[] => [ - ...currentVariables, - ...newVariables.filter( - (newVariable) => - !currentVariables.find( - (currentVariable) => currentVariable.id === newVariable.id - ) - ), -] + .mutation(({ input: { sessionId }, ctx: { user } }) => + updateTypebotInSessionFn({ user, sessionId }) + ) diff --git a/chatApi.Dockerfile b/chatApi.Dockerfile new file mode 100644 index 0000000000..51fd9079eb --- /dev/null +++ b/chatApi.Dockerfile @@ -0,0 +1,21 @@ +FROM oven/bun + +WORKDIR /app + +COPY . . + +RUN apt-get -qy update && apt-get -qy --no-install-recommends install openssl ca-certificates git -y && update-ca-certificates + +RUN bun install + +# Need Node for Prisma +COPY --from=node:18 /usr/local/bin/node /usr/local/bin/node +RUN bun /app/packages/prisma/scripts/db-exec.ts "bunx prisma generate" + +RUN rm -rf /usr/local/bin/node +RUN rm -rf /app/apps/builder +RUN rm -rf /app/apps/viewer + +ENV PORT=3000 +EXPOSE 3000 +CMD ["bun", "run", "apps/chat-api/src/index.ts"] diff --git a/package.json b/package.json index 5b3f585aef..be2723cc37 100644 --- a/package.json +++ b/package.json @@ -3,12 +3,20 @@ "name": "typebot-os", "private": true, "license": "AGPL-3.0-or-later", + "workspaces": [ + "packages/*", + "packages/deprecated/*", + "packages/embeds/*", + "packages/forge/*", + "packages/forge/blocks/*", + "apps/*" + ], "scripts": { "prepare": "husky install", "docker:up": "docker compose -f docker-compose.dev.yml up -d && node -e \"setTimeout(() => {}, 5000)\"", "docker:nuke": "docker compose -f docker-compose.dev.yml down --volumes --remove-orphans", "lint": "turbo run lint", - "dev": "pnpm docker:up && turbo build --filter=@typebot.io/nextjs... && turbo run dev --filter=builder... --filter=viewer... --parallel --no-cache", + "dev": "pnpm docker:up && turbo build --filter=@typebot.io/nextjs... && turbo run dev --filter=builder... --filter=viewer... --filter=chat-api... --parallel --no-cache", "build": "pnpm docker:up && turbo run build", "build:apps": "turbo run build --filter=builder... --filter=viewer...", "db:migrate": "cd packages/prisma && pnpm run db:migrate", diff --git a/packages/bot-engine/apiHandlers/continueChat.ts b/packages/bot-engine/apiHandlers/continueChat.ts new file mode 100644 index 0000000000..86aa41ba26 --- /dev/null +++ b/packages/bot-engine/apiHandlers/continueChat.ts @@ -0,0 +1,102 @@ +import { TRPCError } from '@trpc/server' +import { isDefined, isNotDefined } from '@typebot.io/lib/utils' +import { getSession } from '../queries/getSession' +import { continueBotFlow } from '../continueBotFlow' +import { filterPotentiallySensitiveLogs } from '../logs/filterPotentiallySensitiveLogs' +import { parseDynamicTheme } from '../parseDynamicTheme' +import { saveStateToDatabase } from '../saveStateToDatabase' +import { computeCurrentProgress } from '../computeCurrentProgress' + +type Props = { + origin: string | undefined + message?: string + sessionId: string +} +export const continueChat = async ({ origin, sessionId, message }: Props) => { + 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.', + }) + + let corsOrigin + + if ( + session?.state.allowedOrigins && + session.state.allowedOrigins.length > 0 + ) { + if (origin && session.state.allowedOrigins.includes(origin)) + corsOrigin = origin + else corsOrigin = session.state.allowedOrigins[0] + } + + const { + messages, + input, + clientSideActions, + newSessionState, + logs, + lastMessageNewFormat, + visitedEdges, + } = await continueBotFlow(message, { + version: 2, + state: session.state, + startTime: Date.now(), + }) + + if (newSessionState) + await saveStateToDatabase({ + session: { + id: session.id, + state: newSessionState, + }, + input, + logs, + clientSideActions, + visitedEdges, + hasCustomEmbedBubble: messages.some( + (message) => message.type === 'custom-embed' + ), + }) + + const isPreview = isNotDefined(session.state.typebotsQueue[0].resultId) + + const isEnded = + newSessionState.progressMetadata && + !input?.id && + (clientSideActions?.filter((c) => c.expectsDedicatedReply).length ?? 0) === + 0 + + return { + messages, + input, + clientSideActions, + dynamicTheme: parseDynamicTheme(newSessionState), + logs: isPreview ? logs : logs?.filter(filterPotentiallySensitiveLogs), + lastMessageNewFormat, + corsOrigin, + progress: newSessionState.progressMetadata + ? isEnded + ? 100 + : computeCurrentProgress({ + typebotsQueue: newSessionState.typebotsQueue, + progressMetadata: newSessionState.progressMetadata, + currentInputBlockId: input?.id, + }) + : undefined, + } +} diff --git a/packages/bot-engine/apiHandlers/getMessageStream.ts b/packages/bot-engine/apiHandlers/getMessageStream.ts new file mode 100644 index 0000000000..13baa0e92b --- /dev/null +++ b/packages/bot-engine/apiHandlers/getMessageStream.ts @@ -0,0 +1,130 @@ +import { IntegrationBlockType } from '@typebot.io/schemas/features/blocks/integrations/constants' +import { ChatCompletionOpenAIOptions } from '@typebot.io/schemas/features/blocks/integrations/openai' +import { OpenAI } from 'openai' +import { decryptV2 } from '@typebot.io/lib/api/encryption/decryptV2' +import { forgedBlocks } from '@typebot.io/forge-repository/definitions' +import { ReadOnlyVariableStore } from '@typebot.io/forge' +import { + ParseVariablesOptions, + parseVariables, +} from '@typebot.io/variables/parseVariables' +import { getOpenAIChatCompletionStream } from './legacy/getOpenAIChatCompletionStream' +import { getCredentials } from '../queries/getCredentials' +import { getSession } from '../queries/getSession' +import { getBlockById } from '@typebot.io/schemas/helpers' +import { isForgedBlockType } from '@typebot.io/schemas/features/blocks/forged/helpers' + +type Props = { + sessionId: string + messages: OpenAI.Chat.ChatCompletionMessage[] | undefined +} + +export const getMessageStream = async ({ sessionId, messages }: Props) => { + const session = await getSession(sessionId) + + if (!session?.state || !session.state.currentBlockId) + return { status: 404, message: 'Could not find session' } + + const { group, block } = getBlockById( + session.state.currentBlockId, + session.state.typebotsQueue[0].typebot.groups + ) + if (!block || !group) + return { + status: 404, + message: 'Could not find block or group', + } + + if (!('options' in block)) + return { + status: 400, + message: 'This block does not have options', + } + + if (block.type === IntegrationBlockType.OPEN_AI && messages) { + try { + const stream = await getOpenAIChatCompletionStream( + session.state, + block.options as ChatCompletionOpenAIOptions, + messages + ) + if (!stream) + return { + status: 500, + message: 'Could not create stream', + } + + return { stream } + } catch (error) { + if (error instanceof OpenAI.APIError) { + const { message } = error + return { + status: 500, + message, + } + } else { + throw error + } + } + } + if (!isForgedBlockType(block.type)) + return { + status: 400, + message: 'This block does not have a stream function', + } + + const blockDef = forgedBlocks[block.type] + const action = blockDef?.actions.find((a) => a.name === block.options?.action) + + if (!action || !action.run?.stream) + return { + status: 400, + message: 'This block does not have a stream function', + } + + try { + if (!block.options.credentialsId) + return { status: 404, message: 'Could not find credentials' } + const credentials = await getCredentials(block.options.credentialsId) + if (!credentials) + return { status: 404, message: 'Could not find credentials' } + const decryptedCredentials = await decryptV2( + credentials.data, + credentials.iv + ) + const variables: ReadOnlyVariableStore = { + list: () => session.state.typebotsQueue[0].typebot.variables, + get: (id: string) => { + const variable = session.state.typebotsQueue[0].typebot.variables.find( + (variable) => variable.id === id + ) + return variable?.value + }, + parse: (text: string, params?: ParseVariablesOptions) => + parseVariables( + session.state.typebotsQueue[0].typebot.variables, + params + )(text), + } + const stream = await action.run.stream.run({ + credentials: decryptedCredentials, + options: block.options, + variables, + }) + if (!stream) return { status: 500, message: 'Could not create stream' } + + return { stream } + } catch (error) { + if (error instanceof OpenAI.APIError) { + const { message } = error + return { + status: 500, + message, + } + } + return { + status: 500, + message: 'Could not create stream', + } + } +} diff --git a/packages/bot-engine/apiHandlers/legacy/getOpenAIChatCompletionStream.ts b/packages/bot-engine/apiHandlers/legacy/getOpenAIChatCompletionStream.ts new file mode 100644 index 0000000000..4be67acc7b --- /dev/null +++ b/packages/bot-engine/apiHandlers/legacy/getOpenAIChatCompletionStream.ts @@ -0,0 +1,58 @@ +import { decryptV2 } from '@typebot.io/lib/api/encryption/decryptV2' +import { isNotEmpty } from '@typebot.io/lib/utils' +import { + ChatCompletionOpenAIOptions, + OpenAICredentials, +} from '@typebot.io/schemas/features/blocks/integrations/openai' +import { SessionState } from '@typebot.io/schemas/features/chat/sessionState' +import { OpenAIStream } from 'ai' +import { parseVariableNumber } from '@typebot.io/variables/parseVariableNumber' +import { ClientOptions, OpenAI } from 'openai' +import { defaultOpenAIOptions } from '@typebot.io/schemas/features/blocks/integrations/openai/constants' +import { getCredentials } from '../../queries/getCredentials' + +export const getOpenAIChatCompletionStream = async ( + state: SessionState, + options: ChatCompletionOpenAIOptions, + messages: OpenAI.Chat.ChatCompletionMessageParam[] +) => { + if (!options.credentialsId) return + const credentials = await getCredentials(options.credentialsId) + if (!credentials) { + console.error('Could not find credentials in database') + return + } + const { apiKey } = (await decryptV2( + credentials.data, + credentials.iv + )) as OpenAICredentials['data'] + + const { typebot } = state.typebotsQueue[0] + const temperature = parseVariableNumber(typebot.variables)( + options.advancedSettings?.temperature + ) + + const config = { + apiKey, + baseURL: options.baseUrl, + defaultHeaders: { + 'api-key': apiKey, + }, + defaultQuery: isNotEmpty(options.apiVersion) + ? { + 'api-version': options.apiVersion, + } + : undefined, + } satisfies ClientOptions + + const openai = new OpenAI(config) + + const response = await openai.chat.completions.create({ + model: options.model ?? defaultOpenAIOptions.model, + temperature, + stream: true, + messages, + }) + + return OpenAIStream(response) +} diff --git a/packages/bot-engine/apiHandlers/receiveMessage.ts b/packages/bot-engine/apiHandlers/receiveMessage.ts new file mode 100644 index 0000000000..de79f04b6a --- /dev/null +++ b/packages/bot-engine/apiHandlers/receiveMessage.ts @@ -0,0 +1,35 @@ +import { WhatsAppWebhookRequestBody } from '@typebot.io/schemas/features/whatsapp' +import { isNotDefined } from '@typebot.io/lib' +import { resumeWhatsAppFlow } from '../whatsapp/resumeWhatsAppFlow' + +type Props = { + entry: WhatsAppWebhookRequestBody['entry'] + credentialsId: string + workspaceId: string +} + +export const receiveMessage = async ({ + entry, + credentialsId, + workspaceId, +}: Props) => { + const receivedMessage = entry.at(0)?.changes.at(0)?.value.messages?.at(0) + if (isNotDefined(receivedMessage)) return { message: 'No message found' } + const contactName = + entry.at(0)?.changes.at(0)?.value?.contacts?.at(0)?.profile?.name ?? '' + const contactPhoneNumber = + entry.at(0)?.changes.at(0)?.value?.messages?.at(0)?.from ?? '' + const phoneNumberId = entry.at(0)?.changes.at(0)?.value + .metadata.phone_number_id + if (!phoneNumberId) return { message: 'No phone number id found' } + return resumeWhatsAppFlow({ + receivedMessage, + sessionId: `wa-${phoneNumberId}-${receivedMessage.from}`, + credentialsId, + workspaceId, + contact: { + name: contactName, + phoneNumber: contactPhoneNumber, + }, + }) +} diff --git a/packages/bot-engine/apiHandlers/receiveMessagePreview.ts b/packages/bot-engine/apiHandlers/receiveMessagePreview.ts new file mode 100644 index 0000000000..96e273ed23 --- /dev/null +++ b/packages/bot-engine/apiHandlers/receiveMessagePreview.ts @@ -0,0 +1,31 @@ +import { WhatsAppWebhookRequestBody } from '@typebot.io/schemas/features/whatsapp' +import { isNotDefined } from '@typebot.io/lib' +import { TRPCError } from '@trpc/server' +import { env } from '@typebot.io/env' +import { resumeWhatsAppFlow } from '../whatsapp/resumeWhatsAppFlow' + +type Props = { + entry: WhatsAppWebhookRequestBody['entry'] +} +export const receiveMessagePreview = ({ entry }: Props) => { + if (!env.WHATSAPP_PREVIEW_FROM_PHONE_NUMBER_ID) + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'WHATSAPP_PREVIEW_FROM_PHONE_NUMBER_ID is not defined', + }) + const receivedMessage = entry.at(0)?.changes.at(0)?.value.messages?.at(0) + if (isNotDefined(receivedMessage)) return { message: 'No message found' } + const contactName = + entry.at(0)?.changes.at(0)?.value?.contacts?.at(0)?.profile?.name ?? '' + const contactPhoneNumber = + entry.at(0)?.changes.at(0)?.value?.messages?.at(0)?.from ?? '' + + return resumeWhatsAppFlow({ + receivedMessage, + sessionId: `wa-preview-${receivedMessage.from}`, + contact: { + name: contactName, + phoneNumber: contactPhoneNumber, + }, + }) +} diff --git a/packages/bot-engine/apiHandlers/saveClientLogs.ts b/packages/bot-engine/apiHandlers/saveClientLogs.ts new file mode 100644 index 0000000000..297cdb4ef3 --- /dev/null +++ b/packages/bot-engine/apiHandlers/saveClientLogs.ts @@ -0,0 +1,49 @@ +import { TRPCError } from '@trpc/server' +import { ChatLog } from '@typebot.io/schemas' +import { formatLogDetails } from '../logs/helpers/formatLogDetails' +import { getSession } from '../queries/getSession' +import { saveLogs } from '../queries/saveLogs' + +type Props = { + sessionId: string + clientLogs: ChatLog[] +} + +export const saveClientLogs = async ({ sessionId, clientLogs }: Props) => { + 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) + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to save logs.', + }) + } +} diff --git a/packages/bot-engine/apiHandlers/startChat.ts b/packages/bot-engine/apiHandlers/startChat.ts new file mode 100644 index 0000000000..2cd6bfc9ee --- /dev/null +++ b/packages/bot-engine/apiHandlers/startChat.ts @@ -0,0 +1,107 @@ +import { computeCurrentProgress } from '../computeCurrentProgress' +import { filterPotentiallySensitiveLogs } from '../logs/filterPotentiallySensitiveLogs' +import { restartSession } from '../queries/restartSession' +import { saveStateToDatabase } from '../saveStateToDatabase' +import { startSession } from '../startSession' + +type Props = { + origin: string | undefined + message?: string + isOnlyRegistering: boolean + publicId: string + isStreamEnabled: boolean + prefilledVariables?: Record + resultId?: string +} + +export const startChat = async ({ + origin, + message, + isOnlyRegistering, + publicId, + isStreamEnabled, + prefilledVariables, + resultId: startResultId, +}: Props) => { + const { + typebot, + messages, + input, + resultId, + dynamicTheme, + logs, + clientSideActions, + newSessionState, + visitedEdges, + } = await startSession({ + version: 2, + startParams: { + type: 'live', + isOnlyRegistering, + isStreamEnabled, + publicId, + prefilledVariables, + resultId: startResultId, + }, + message, + }) + + let corsOrigin + + if ( + newSessionState.allowedOrigins && + newSessionState.allowedOrigins.length > 0 + ) { + if (origin && newSessionState.allowedOrigins.includes(origin)) + corsOrigin = origin + else corsOrigin = newSessionState.allowedOrigins[0] + } + + const session = isOnlyRegistering + ? await restartSession({ + state: newSessionState, + }) + : await saveStateToDatabase({ + session: { + state: newSessionState, + }, + input, + logs, + clientSideActions, + visitedEdges, + hasCustomEmbedBubble: messages.some( + (message) => message.type === 'custom-embed' + ), + }) + + const isEnded = + newSessionState.progressMetadata && + !input?.id && + (clientSideActions?.filter((c) => c.expectsDedicatedReply).length ?? 0) === + 0 + + return { + sessionId: session.id, + typebot: { + id: typebot.id, + theme: typebot.theme, + settings: typebot.settings, + }, + messages, + input, + resultId, + dynamicTheme, + logs: logs?.filter(filterPotentiallySensitiveLogs), + clientSideActions, + corsOrigin, + progress: newSessionState.progressMetadata + ? isEnded + ? 100 + : computeCurrentProgress({ + typebotsQueue: newSessionState.typebotsQueue, + progressMetadata: newSessionState.progressMetadata, + currentInputBlockId: input?.id, + }) + : undefined, + } +} diff --git a/packages/bot-engine/apiHandlers/startChatPreview.ts b/packages/bot-engine/apiHandlers/startChatPreview.ts new file mode 100644 index 0000000000..406b52ce63 --- /dev/null +++ b/packages/bot-engine/apiHandlers/startChatPreview.ts @@ -0,0 +1,97 @@ +import { StartFrom, StartTypebot } from '@typebot.io/schemas' +import { restartSession } from '../queries/restartSession' +import { saveStateToDatabase } from '../saveStateToDatabase' +import { startSession } from '../startSession' +import { computeCurrentProgress } from '../computeCurrentProgress' + +type Props = { + message?: string + isOnlyRegistering: boolean + isStreamEnabled: boolean + startFrom?: StartFrom + typebotId: string + typebot?: StartTypebot + userId?: string + prefilledVariables?: Record +} + +export const startChatPreview = async ({ + message, + isOnlyRegistering, + isStreamEnabled, + startFrom, + typebotId, + typebot: startTypebot, + userId, + prefilledVariables, +}: Props) => { + const { + typebot, + messages, + input, + dynamicTheme, + logs, + clientSideActions, + newSessionState, + visitedEdges, + } = await startSession({ + version: 2, + startParams: { + type: 'preview', + isOnlyRegistering, + isStreamEnabled, + startFrom, + typebotId, + typebot: startTypebot, + userId, + prefilledVariables, + }, + message, + }) + + const session = isOnlyRegistering + ? await restartSession({ + state: newSessionState, + }) + : await saveStateToDatabase({ + session: { + state: newSessionState, + }, + input, + logs, + clientSideActions, + visitedEdges, + hasCustomEmbedBubble: messages.some( + (message) => message.type === 'custom-embed' + ), + }) + + const isEnded = + newSessionState.progressMetadata && + !input?.id && + (clientSideActions?.filter((c) => c.expectsDedicatedReply).length ?? 0) === + 0 + + return { + sessionId: session.id, + typebot: { + id: typebot.id, + theme: typebot.theme, + settings: typebot.settings, + }, + messages, + input, + dynamicTheme, + logs, + clientSideActions, + progress: newSessionState.progressMetadata + ? isEnded + ? 100 + : computeCurrentProgress({ + typebotsQueue: newSessionState.typebotsQueue, + progressMetadata: newSessionState.progressMetadata, + currentInputBlockId: input?.id, + }) + : undefined, + } +} diff --git a/packages/bot-engine/apiHandlers/updateTypebotInSession.ts b/packages/bot-engine/apiHandlers/updateTypebotInSession.ts new file mode 100644 index 0000000000..83cc0f91a0 --- /dev/null +++ b/packages/bot-engine/apiHandlers/updateTypebotInSession.ts @@ -0,0 +1,97 @@ +import { TRPCError } from '@trpc/server' +import prisma from '@typebot.io/lib/prisma' +import { + SessionState, + Variable, + PublicTypebot, + Typebot, +} from '@typebot.io/schemas' +import { getSession } from '../queries/getSession' + +type Props = { + user?: { id: string } + sessionId: string +} + +export const updateTypebotInSession = async ({ user, sessionId }: Props) => { + 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' }) + + const publicTypebot = (await prisma.publicTypebot.findFirst({ + where: { + typebot: { + id: session.state.typebotsQueue[0].typebot.id, + OR: [ + { + workspace: { + members: { + some: { userId: user.id, role: { in: ['ADMIN', 'MEMBER'] } }, + }, + }, + }, + { + collaborators: { + some: { userId: user.id, type: { in: ['WRITE'] } }, + }, + }, + ], + }, + }, + select: { + edges: true, + groups: true, + variables: true, + }, + })) as Pick | null + + if (!publicTypebot) + throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Unauthorized' }) + + const newSessionState = updateSessionState(session.state, publicTypebot) + + await prisma.chatSession.updateMany({ + where: { id: session.id }, + data: { state: newSessionState }, + }) + + return { message: 'success' } as const +} + +const updateSessionState = ( + currentState: SessionState, + newTypebot: Pick +): SessionState => ({ + ...currentState, + typebotsQueue: currentState.typebotsQueue.map((typebotInQueue, index) => + index === 0 + ? { + ...typebotInQueue, + typebot: { + ...typebotInQueue.typebot, + edges: newTypebot.edges, + groups: newTypebot.groups, + variables: updateVariablesInSession( + typebotInQueue.typebot.variables, + newTypebot.variables + ), + }, + } + : typebotInQueue + ) as SessionState['typebotsQueue'], +}) + +const updateVariablesInSession = ( + currentVariables: Variable[], + newVariables: Typebot['variables'] +): Variable[] => [ + ...currentVariables, + ...newVariables.filter( + (newVariable) => + !currentVariables.find( + (currentVariable) => currentVariable.id === newVariable.id + ) + ), +] diff --git a/packages/bot-engine/blocks/integrations/legacy/openai/createChatCompletionOpenAI.ts b/packages/bot-engine/blocks/integrations/legacy/openai/createChatCompletionOpenAI.ts index 1baa979af0..e908acc849 100644 --- a/packages/bot-engine/blocks/integrations/legacy/openai/createChatCompletionOpenAI.ts +++ b/packages/bot-engine/blocks/integrations/legacy/openai/createChatCompletionOpenAI.ts @@ -84,14 +84,13 @@ export const createChatCompletionOpenAI = async ( )?.name if ( - isPlaneteScale() && - isCredentialsV2(credentials) && newSessionState.isStreamEnabled && !newSessionState.whatsApp && isNextBubbleMessageWithAssistantMessage(typebot)( blockId, assistantMessageVariableName - ) + ) && + !process.env.VERCEL_ENV ) { return { clientSideActions: [ diff --git a/packages/bot-engine/blocks/integrations/zemanticAi/executeZemanticAiBlock.ts b/packages/bot-engine/blocks/integrations/zemanticAi/executeZemanticAiBlock.ts index edaba54877..9c28c29594 100644 --- a/packages/bot-engine/blocks/integrations/zemanticAi/executeZemanticAiBlock.ts +++ b/packages/bot-engine/blocks/integrations/zemanticAi/executeZemanticAiBlock.ts @@ -7,9 +7,9 @@ import { import got from 'got' import { decrypt } from '@typebot.io/lib/api/encryption/decrypt' import { byId, isDefined, isEmpty } from '@typebot.io/lib' -import prisma from '@typebot.io/lib/prisma' import { ExecuteIntegrationResponse } from '../../../types' import { updateVariablesInSession } from '@typebot.io/variables/updateVariablesInSession' +import { getCredentials } from '../../../queries/getCredentials' import { parseAnswers } from '@typebot.io/results/parseAnswers' const URL = 'https://api.zemantic.ai/v1/search-documents' @@ -25,11 +25,7 @@ export const executeZemanticAiBlock = async ( outgoingEdgeId: block.outgoingEdgeId, } - const credentials = await prisma.credentials.findUnique({ - where: { - id: block.options?.credentialsId, - }, - }) + const credentials = await getCredentials(block.options.credentialsId) if (!credentials) { return { diff --git a/packages/bot-engine/forge/executeForgedBlock.ts b/packages/bot-engine/forge/executeForgedBlock.ts index 90937f23d3..5b5f4c751c 100644 --- a/packages/bot-engine/forge/executeForgedBlock.ts +++ b/packages/bot-engine/forge/executeForgedBlock.ts @@ -19,6 +19,8 @@ import { updateVariablesInSession } from '@typebot.io/variables/updateVariablesI import { ExecuteIntegrationResponse } from '../types' import { byId } from '@typebot.io/lib' import { BubbleBlockType } from '@typebot.io/schemas/features/blocks/bubbles/constants' +import { env } from '@typebot.io/env' +import { getCredentials } from '../queries/getCredentials' export const executeForgedBlock = async ( state: SessionState, @@ -40,11 +42,7 @@ export const executeForgedBlock = async ( logs: [noCredentialsError], } } - credentials = await prisma.credentials.findUnique({ - where: { - id: block.options.credentialsId, - }, - }) + credentials = await getCredentials(block.options.credentialsId) if (!credentials) { console.error('Could not find credentials in database') return { @@ -57,15 +55,13 @@ export const executeForgedBlock = async ( const typebot = state.typebotsQueue[0].typebot if ( action?.run?.stream && - isPlaneteScale() && - credentials && - isCredentialsV2(credentials) && - state.isStreamEnabled && - !state.whatsApp && isNextBubbleTextWithStreamingVar(typebot)( block.id, action.run.stream.getStreamVariableId(block.options) - ) + ) && + state.isStreamEnabled && + !state.whatsApp && + !process.env.VERCEL_ENV ) { return { outgoingEdgeId: block.outgoingEdgeId, diff --git a/packages/bot-engine/package.json b/packages/bot-engine/package.json index 33a568074e..3e0a17b2ee 100644 --- a/packages/bot-engine/package.json +++ b/packages/bot-engine/package.json @@ -25,9 +25,10 @@ "google-auth-library": "8.9.0", "google-spreadsheet": "4.1.1", "got": "12.6.0", + "ky": "^1.1.3", "libphonenumber-js": "1.10.37", "node-html-parser": "6.1.5", - "nodemailer": "6.9.3", + "nodemailer": "6.9.8", "openai": "4.28.4", "qs": "6.11.2", "stripe": "12.13.0", @@ -36,7 +37,7 @@ "devDependencies": { "@typebot.io/forge": "workspace:*", "@typebot.io/forge-repository": "workspace:*", - "@types/nodemailer": "6.4.8", + "@types/nodemailer": "6.4.14", "@types/qs": "6.9.7" } } diff --git a/packages/bot-engine/queries/getCredentials.ts b/packages/bot-engine/queries/getCredentials.ts new file mode 100644 index 0000000000..c182e2cf5a --- /dev/null +++ b/packages/bot-engine/queries/getCredentials.ts @@ -0,0 +1,6 @@ +import prisma from '@typebot.io/lib/prisma' + +export const getCredentials = async (credentialsId: string) => + prisma.credentials.findUnique({ + where: { id: credentialsId }, + }) diff --git a/packages/bot-engine/whatsapp/sendChatReplyToWhatsApp.ts b/packages/bot-engine/whatsapp/sendChatReplyToWhatsApp.ts index c268b7dc3f..c7b5ffbc3c 100644 --- a/packages/bot-engine/whatsapp/sendChatReplyToWhatsApp.ts +++ b/packages/bot-engine/whatsapp/sendChatReplyToWhatsApp.ts @@ -10,7 +10,7 @@ import { import { convertMessageToWhatsAppMessage } from './convertMessageToWhatsAppMessage' import { sendWhatsAppMessage } from './sendWhatsAppMessage' import * as Sentry from '@sentry/nextjs' -import { HTTPError } from 'got' +import { HTTPError } from 'ky' import { convertInputToWhatsAppMessages } from './convertInputToWhatsAppMessage' import { isNotDefined } from '@typebot.io/lib/utils' import { computeTypingDuration } from '../computeTypingDuration' @@ -141,7 +141,7 @@ export const sendChatReplyToWhatsApp = async ({ Sentry.captureException(err, { extra: { message } }) console.log('Failed to send message:', JSON.stringify(message, null, 2)) if (err instanceof HTTPError) - console.log('HTTPError', err.response.statusCode, err.response.body) + console.log('HTTPError', err.response.status, err.response.body) } } @@ -172,7 +172,7 @@ export const sendChatReplyToWhatsApp = async ({ Sentry.captureException(err, { extra: { message } }) console.log('Failed to send message:', JSON.stringify(message, null, 2)) if (err instanceof HTTPError) - console.log('HTTPError', err.response.statusCode, err.response.body) + console.log('HTTPError', err.response.status, err.response.body) } } } @@ -253,7 +253,7 @@ const executeClientSideAction = Sentry.captureException(err, { extra: { message } }) console.log('Failed to send message:', JSON.stringify(message, null, 2)) if (err instanceof HTTPError) - console.log('HTTPError', err.response.statusCode, err.response.body) + console.log('HTTPError', err.response.status, err.response.body) } } } diff --git a/packages/bot-engine/whatsapp/sendWhatsAppMessage.ts b/packages/bot-engine/whatsapp/sendWhatsAppMessage.ts index 2bcf8bf864..55c5e9ce1e 100644 --- a/packages/bot-engine/whatsapp/sendWhatsAppMessage.ts +++ b/packages/bot-engine/whatsapp/sendWhatsAppMessage.ts @@ -1,4 +1,4 @@ -import got from 'got' +import ky from 'ky' import { WhatsAppCredentials, WhatsAppSendingMessage, @@ -16,14 +16,16 @@ export const sendWhatsAppMessage = async ({ message, credentials, }: Props) => - got.post({ - url: `${env.WHATSAPP_CLOUD_API_URL}/v17.0/${credentials.phoneNumberId}/messages`, - headers: { - Authorization: `Bearer ${credentials.systemUserAccessToken}`, - }, - json: { - messaging_product: 'whatsapp', - to, - ...message, - }, - }) + ky.post( + `${env.WHATSAPP_CLOUD_API_URL}/v17.0/${credentials.phoneNumberId}/messages`, + { + headers: { + Authorization: `Bearer ${credentials.systemUserAccessToken}`, + }, + json: { + messaging_product: 'whatsapp', + to, + ...message, + }, + } + ) diff --git a/packages/emails/package.json b/packages/emails/package.json index 9c3f3bd0c4..e6bd1d876c 100644 --- a/packages/emails/package.json +++ b/packages/emails/package.json @@ -17,11 +17,11 @@ "devDependencies": { "@faire/mjml-react": "3.3.0", "@types/node": "20.4.2", - "@types/nodemailer": "6.4.8", + "@types/nodemailer": "6.4.14", "@types/react": "18.2.15", "concurrently": "8.2.0", "http-server": "14.1.1", - "nodemailer": "6.9.3", + "nodemailer": "6.9.8", "react": "18.2.0", "tsx": "3.12.7", "@typebot.io/lib": "workspace:*", diff --git a/packages/embeds/js/src/features/blocks/inputs/fileUpload/components/FileUploadForm.tsx b/packages/embeds/js/src/features/blocks/inputs/fileUpload/components/FileUploadForm.tsx index aa989cce4b..79b3cf2d75 100644 --- a/packages/embeds/js/src/features/blocks/inputs/fileUpload/components/FileUploadForm.tsx +++ b/packages/embeds/js/src/features/blocks/inputs/fileUpload/components/FileUploadForm.tsx @@ -73,7 +73,8 @@ export const FileUploadForm = (props: Props) => { }) setIsUploading(true) const urls = await uploadFiles({ - apiHost: props.context.apiHost ?? guessApiHost(), + apiHost: + props.context.apiHost ?? guessApiHost({ ignoreChatApiUrl: true }), files: [ { file, @@ -112,7 +113,8 @@ export const FileUploadForm = (props: Props) => { }) setIsUploading(true) const urls = await uploadFiles({ - apiHost: props.context.apiHost ?? guessApiHost(), + apiHost: + props.context.apiHost ?? guessApiHost({ ignoreChatApiUrl: true }), files: files.map((file) => ({ file: file, input: { diff --git a/packages/embeds/js/src/features/blocks/integrations/openai/streamChat.ts b/packages/embeds/js/src/features/blocks/integrations/openai/streamChat.ts index cd8dd52b02..5a925a2fd8 100644 --- a/packages/embeds/js/src/features/blocks/integrations/openai/streamChat.ts +++ b/packages/embeds/js/src/features/blocks/integrations/openai/streamChat.ts @@ -25,16 +25,15 @@ export const streamChat = const apiHost = context.apiHost const res = await fetch( - `${ - isNotEmpty(apiHost) ? apiHost : guessApiHost() - }/api/integrations/openai/streamer`, + `${isNotEmpty(apiHost) ? apiHost : guessApiHost()}/api/v1/sessions/${ + context.sessionId + }/streamMessage`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ - sessionId: context.sessionId, messages, }), signal: abortController.signal, diff --git a/packages/embeds/js/src/queries/startChatQuery.ts b/packages/embeds/js/src/queries/startChatQuery.ts index 7ad3923bf3..36b0dedfcf 100644 --- a/packages/embeds/js/src/queries/startChatQuery.ts +++ b/packages/embeds/js/src/queries/startChatQuery.ts @@ -83,7 +83,10 @@ export async function startChatQuery({ startFrom, typebot, prefilledVariables, - } satisfies Omit, + } satisfies Omit< + StartPreviewChatInput, + 'typebotId' | 'isOnlyRegistering' + >, timeout: false, } ) diff --git a/packages/embeds/js/src/utils/guessApiHost.ts b/packages/embeds/js/src/utils/guessApiHost.ts index eed5d313e7..aee85a2e5e 100644 --- a/packages/embeds/js/src/utils/guessApiHost.ts +++ b/packages/embeds/js/src/utils/guessApiHost.ts @@ -1,6 +1,29 @@ import { getRuntimeVariable } from '@typebot.io/env/getRuntimeVariable' -const cloudViewerUrl = 'https://typebot.io' +const chatApiCloudFallbackHost = 'https://chat.typebot.io' -export const guessApiHost = () => - getRuntimeVariable('NEXT_PUBLIC_VIEWER_URL')?.split(',')[0] ?? cloudViewerUrl +type Params = { + ignoreChatApiUrl?: boolean +} + +export const guessApiHost = ( + { ignoreChatApiUrl }: Params = { ignoreChatApiUrl: false } +) => { + const chatApiUrl = getRuntimeVariable('NEXT_PUBLIC_CHAT_API_URL') + const newChatApiOnUrls = getRuntimeVariable( + 'NEXT_PUBLIC_USE_EXPERIMENTAL_CHAT_API_ON' + ) + + if ( + !ignoreChatApiUrl && + chatApiUrl && + (!newChatApiOnUrls || newChatApiOnUrls.includes(window.location.href)) + ) { + return chatApiUrl + } + + return ( + getRuntimeVariable('NEXT_PUBLIC_VIEWER_URL')?.split(',')[0] ?? + chatApiCloudFallbackHost + ) +} diff --git a/packages/env/env.ts b/packages/env/env.ts index 4faeade519..38a20f3ef7 100644 --- a/packages/env/env.ts +++ b/packages/env/env.ts @@ -46,7 +46,9 @@ const boolean = z.enum(['true', 'false']).transform((value) => value === 'true') const baseEnv = { server: { - NODE_ENV: z.enum(['development', 'production', 'test']).optional(), + NODE_ENV: z + .enum(['development', 'staging', 'production', 'test']) + .optional(), DATABASE_URL: z .string() .url() @@ -99,6 +101,15 @@ const baseEnv = { ), NEXT_PUBLIC_ONBOARDING_TYPEBOT_ID: z.string().min(1).optional(), NEXT_PUBLIC_BOT_FILE_UPLOAD_MAX_SIZE: z.coerce.number().optional(), + NEXT_PUBLIC_CHAT_API_URL: z.string().url().optional(), + // To remove to deploy chat API for all typebots + NEXT_PUBLIC_USE_EXPERIMENTAL_CHAT_API_ON: z + .string() + .min(1) + .transform((val) => + val.split('/').map((s) => s.split(',').map((s) => s.split('|'))) + ) + .optional(), NEXT_PUBLIC_VIEWER_404_TITLE: z.string().optional().default('404'), NEXT_PUBLIC_VIEWER_404_SUBTITLE: z .string() @@ -114,6 +125,10 @@ const baseEnv = { NEXT_PUBLIC_BOT_FILE_UPLOAD_MAX_SIZE: getRuntimeVariable( 'NEXT_PUBLIC_BOT_FILE_UPLOAD_MAX_SIZE' ), + NEXT_PUBLIC_CHAT_API_URL: getRuntimeVariable('NEXT_PUBLIC_CHAT_API_URL'), + NEXT_PUBLIC_USE_EXPERIMENTAL_CHAT_API_ON: getRuntimeVariable( + 'NEXT_PUBLIC_USE_EXPERIMENTAL_CHAT_API_ON' + ), NEXT_PUBLIC_VIEWER_404_TITLE: getRuntimeVariable( 'NEXT_PUBLIC_VIEWER_404_TITLE' ), diff --git a/packages/lib/package.json b/packages/lib/package.json index 9692d6355a..458d1602db 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -15,7 +15,7 @@ "@types/nodemailer": "6.4.8", "@types/validator": "13.11.9", "next": "14.1.0", - "nodemailer": "6.9.3", + "nodemailer": "6.9.8", "tslib": "2.6.0", "typescript": "5.3.2" }, diff --git a/packages/lib/prisma.ts b/packages/lib/prisma.ts index 2a536817fa..1b9cf6d54d 100644 --- a/packages/lib/prisma.ts +++ b/packages/lib/prisma.ts @@ -4,11 +4,15 @@ import { PrismaClient } from '@typebot.io/prisma' declare const global: { prisma: PrismaClient } let prisma: PrismaClient -if (env.NODE_ENV === 'production') { - prisma = new PrismaClient() +if (env.NODE_ENV === 'production' && !process.versions.bun) { + prisma = new PrismaClient({ + log: ['info', 'warn', 'error'], + }) } else { if (!global.prisma) { - global.prisma = new PrismaClient() + global.prisma = new PrismaClient({ + log: ['info', 'warn', 'error'], + }) } prisma = global.prisma } diff --git a/packages/prisma/scripts/db-exec.ts b/packages/prisma/scripts/db-exec.ts new file mode 100644 index 0000000000..bd45bbd0f3 --- /dev/null +++ b/packages/prisma/scripts/db-exec.ts @@ -0,0 +1,7 @@ +import { executePrismaCommand } from './executeCommand' + +const commandToExecute = process.argv.pop() + +if (!commandToExecute) process.exit(1) + +executePrismaCommand(commandToExecute, { force: true }) diff --git a/packages/schemas/features/chat/schema.ts b/packages/schemas/features/chat/schema.ts index 60faacd458..55a1f86acb 100644 --- a/packages/schemas/features/chat/schema.ts +++ b/packages/schemas/features/chat/schema.ts @@ -225,14 +225,15 @@ export const startPreviewChatInputSchema = z.object({ .describe( "[Where to find my bot's ID?](../how-to#how-to-find-my-typebotid)" ), - isStreamEnabled: z.boolean().optional(), + isStreamEnabled: z.boolean().optional().default(false), message: z.string().optional(), isOnlyRegistering: z .boolean() .optional() .describe( 'If set to `true`, it will only register the session and not start the bot. This is used for 3rd party chat platforms as it can require a session to be registered before sending the first message.' - ), + ) + .default(false), typebot: startTypebotSchema .optional() .describe( diff --git a/packages/schemas/features/whatsapp.ts b/packages/schemas/features/whatsapp.ts index b3ba53be1e..ee56ba2f97 100644 --- a/packages/schemas/features/whatsapp.ts +++ b/packages/schemas/features/whatsapp.ts @@ -174,6 +174,10 @@ export const whatsAppWebhookRequestBodySchema = z.object({ ), }) +export type WhatsAppWebhookRequestBody = z.infer< + typeof whatsAppWebhookRequestBodySchema +> + export const whatsAppCredentialsSchema = z .object({ type: z.literal('whatsApp'), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b26731f18a..ccb6cf929b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -211,13 +211,13 @@ importers: version: 14.1.0(@babel/core@7.22.9)(react-dom@18.2.0)(react@18.2.0) next-auth: specifier: 4.22.1 - version: 4.22.1(next@14.1.0)(nodemailer@6.9.3)(react-dom@18.2.0)(react@18.2.0) + version: 4.22.1(next@14.1.0)(nodemailer@6.9.8)(react-dom@18.2.0)(react@18.2.0) nextjs-cors: specifier: 2.1.2 version: 2.1.2(next@14.1.0) nodemailer: - specifier: 6.9.3 - version: 6.9.3 + specifier: 6.9.8 + version: 6.9.8 nprogress: specifier: 0.2.0 version: 0.2.0 @@ -325,8 +325,8 @@ importers: specifier: 20.4.2 version: 20.4.2 '@types/nodemailer': - specifier: 6.4.8 - version: 6.4.8 + specifier: 6.4.14 + version: 6.4.14 '@types/nprogress': specifier: 0.2.0 version: 0.2.0 @@ -367,6 +367,73 @@ importers: specifier: 3.22.4 version: 3.22.4 + apps/chat-api: + dependencies: + '@hono/prometheus': + specifier: 1.0.0 + version: 1.0.0(hono@4.0.5)(prom-client@15.1.0) + '@hono/sentry': + specifier: 1.0.1 + version: 1.0.1(hono@4.0.5) + '@hono/typebox-validator': + specifier: 0.2.2 + version: 0.2.2(@sinclair/typebox@0.32.5)(hono@4.0.5) + '@sinclair/typebox': + specifier: 0.32.5 + version: 0.32.5 + '@trpc/server': + specifier: 10.40.0 + version: 10.40.0 + '@typebot.io/bot-engine': + specifier: workspace:* + version: link:../../packages/bot-engine + '@typebot.io/env': + specifier: workspace:* + version: link:../../packages/env + '@typebot.io/forge': + specifier: workspace:* + version: link:../../packages/forge/core + '@typebot.io/forge-repository': + specifier: workspace:* + version: link:../../packages/forge/repository + '@typebot.io/lib': + specifier: workspace:* + version: link:../../packages/lib + '@typebot.io/prisma': + specifier: workspace:* + version: link:../../packages/prisma + '@typebot.io/schemas': + specifier: workspace:* + version: link:../../packages/schemas + '@typebot.io/variables': + specifier: workspace:* + version: link:../../packages/variables + ai: + specifier: 3.0.12 + version: 3.0.12(react@18.2.0)(solid-js@1.7.8)(svelte@4.2.12)(vue@3.4.21)(zod@3.22.4) + hono: + specifier: 4.0.5 + version: 4.0.5 + openai: + specifier: 4.28.4 + version: 4.28.4 + prom-client: + specifier: 15.1.0 + version: 15.1.0 + devDependencies: + '@typebot.io/tsconfig': + specifier: workspace:* + version: link:../../packages/tsconfig + '@types/react': + specifier: 18.2.15 + version: 18.2.15 + dotenv-cli: + specifier: 7.2.1 + version: 7.2.1 + react: + specifier: 18.2.0 + version: 18.2.0 + apps/docs: devDependencies: dotenv-cli: @@ -527,8 +594,8 @@ importers: specifier: 2.1.2 version: 2.1.2(next@14.1.0) nodemailer: - specifier: 6.9.3 - version: 6.9.3 + specifier: 6.9.8 + version: 6.9.8 openai: specifier: 4.28.4 version: 4.28.4 @@ -591,8 +658,8 @@ importers: specifier: 20.4.2 version: 20.4.2 '@types/nodemailer': - specifier: 6.4.8 - version: 6.4.8 + specifier: 6.4.14 + version: 6.4.14 '@types/papaparse': specifier: 5.3.7 version: 5.3.7 @@ -711,6 +778,9 @@ importers: got: specifier: 12.6.0 version: 12.6.0 + ky: + specifier: ^1.1.3 + version: 1.2.0 libphonenumber-js: specifier: 1.10.37 version: 1.10.37 @@ -718,8 +788,8 @@ importers: specifier: 6.1.5 version: 6.1.5 nodemailer: - specifier: 6.9.3 - version: 6.9.3 + specifier: 6.9.8 + version: 6.9.8 openai: specifier: 4.28.4 version: 4.28.4 @@ -737,8 +807,8 @@ importers: specifier: workspace:* version: link:../forge/repository '@types/nodemailer': - specifier: 6.4.8 - version: 6.4.8 + specifier: 6.4.14 + version: 6.4.14 '@types/qs': specifier: 6.9.7 version: 6.9.7 @@ -891,8 +961,8 @@ importers: specifier: 20.4.2 version: 20.4.2 '@types/nodemailer': - specifier: 6.4.8 - version: 6.4.8 + specifier: 6.4.14 + version: 6.4.14 '@types/react': specifier: 18.2.15 version: 18.2.15 @@ -909,8 +979,8 @@ importers: specifier: 14.1.1 version: 14.1.1 nodemailer: - specifier: 6.9.3 - version: 6.9.3 + specifier: 6.9.8 + version: 6.9.8 react: specifier: 18.2.0 version: 18.2.0 @@ -1622,8 +1692,8 @@ importers: specifier: 14.1.0 version: 14.1.0(@babel/core@7.22.9)(react-dom@18.2.0)(react@18.2.0) nodemailer: - specifier: 6.9.3 - version: 6.9.3 + specifier: 6.9.8 + version: 6.9.8 tslib: specifier: 2.6.0 version: 2.6.0 @@ -6492,6 +6562,35 @@ packages: - supports-color dev: false + /@hono/prometheus@1.0.0(hono@4.0.5)(prom-client@15.1.0): + resolution: {integrity: sha512-cB4TklEw3CVqOdgLYenl6g4TM1YwYimrE044azQmVLLq7arfJfnWupvzev42LM1+ntlhsAAwS0TfpjSdnQBggw==} + peerDependencies: + hono: ^3.12.0 + prom-client: ^15.0.0 + dependencies: + hono: 4.0.5 + prom-client: 15.1.0 + dev: false + + /@hono/sentry@1.0.1(hono@4.0.5): + resolution: {integrity: sha512-4JgwdyasCQIoH3lhl4yLNxrP4/SElfK01ZV3JUaMvexVJnyAOPuXDhtJasl9Gssg7qDNt8ZIDDjqmVrOwS+AIw==} + peerDependencies: + hono: '>=3.*' + dependencies: + hono: 4.0.5 + toucan-js: 3.3.1 + dev: false + + /@hono/typebox-validator@0.2.2(@sinclair/typebox@0.32.5)(hono@4.0.5): + resolution: {integrity: sha512-6hLnF9Pe+nOWSvX5SIhobZ9Wt7vjTT3sjqDZ5lKALf+9J9XMtNzDyMSjpM5SQv8gg+fOPHu/ZSYxxmUhl65QOg==} + peerDependencies: + '@sinclair/typebox': ^0.31.15 + hono: '>=3.9.0' + dependencies: + '@sinclair/typebox': 0.32.5 + hono: 4.0.5 + dev: false + /@humanwhocodes/config-array@0.11.14: resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==} engines: {node: '>=10.10.0'} @@ -7688,6 +7787,11 @@ packages: /@one-ini/wasm@0.1.1: resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==} + /@opentelemetry/api@1.8.0: + resolution: {integrity: sha512-I/s6F7yKUDdtMsoBWXJe8Qz40Tui5vsuKCWJEWVL+5q9sSWRzzx6v2KeNsOBEwd94j0eWkpWCH4yB6rZg9Mf0w==} + engines: {node: '>=8.0.0'} + dev: false + /@panva/hkdf@1.1.1: resolution: {integrity: sha512-dhPeilub1NuIG0X5Kvhh9lH4iW3ZsHlnzwgwbOlgwQ2wG1IqFzsgHqmKPk3WzsdWAeaxKJxgM0+W433RmN45GA==} dev: false @@ -8800,6 +8904,14 @@ packages: - supports-color dev: false + /@sentry/core@7.76.0: + resolution: {integrity: sha512-M+ptkCTeCNf6fn7p2MmEb1Wd9/JXUWxIT/0QEc+t11DNR4FYy1ZP2O9Zb3Zp2XacO7ORrlL3Yc+VIfl5JTgjfw==} + engines: {node: '>=8'} + dependencies: + '@sentry/types': 7.76.0 + '@sentry/utils': 7.76.0 + dev: false + /@sentry/core@7.77.0: resolution: {integrity: sha512-Tj8oTYFZ/ZD+xW8IGIsU6gcFXD/gfE+FUxUaeSosd9KHwBQNOLhZSsYo/tTVf/rnQI/dQnsd4onPZLiL+27aTg==} engines: {node: '>=8'} @@ -8808,6 +8920,16 @@ packages: '@sentry/utils': 7.77.0 dev: false + /@sentry/integrations@7.76.0: + resolution: {integrity: sha512-4ea0PNZrGN9wKuE/8bBCRrxxw4Cq5T710y8rhdKHAlSUpbLqr/atRF53h8qH3Fi+ec0m38PB+MivKem9zUwlwA==} + engines: {node: '>=8'} + dependencies: + '@sentry/core': 7.76.0 + '@sentry/types': 7.76.0 + '@sentry/utils': 7.76.0 + localforage: 1.10.0 + dev: false + /@sentry/integrations@7.77.0: resolution: {integrity: sha512-P055qXgBHeZNKnnVEs5eZYLdy6P49Zr77A1aWJuNih/EenzMy922GOeGy2mF6XYrn1YJSjEwsNMNsQkcvMTK8Q==} engines: {node: '>=8'} @@ -8885,11 +9007,23 @@ packages: '@sentry/utils': 7.77.0 dev: false + /@sentry/types@7.76.0: + resolution: {integrity: sha512-vj6z+EAbVrKAXmJPxSv/clpwS9QjPqzkraMFk2hIdE/kii8s8kwnkBwTSpIrNc8GnzV3qYC4r3qD+BXDxAGPaw==} + engines: {node: '>=8'} + dev: false + /@sentry/types@7.77.0: resolution: {integrity: sha512-nfb00XRJVi0QpDHg+JkqrmEBHsqBnxJu191Ded+Cs1OJ5oPXEW6F59LVcBScGvMqe+WEk1a73eH8XezwfgrTsA==} engines: {node: '>=8'} dev: false + /@sentry/utils@7.76.0: + resolution: {integrity: sha512-40jFD+yfQaKpFYINghdhovzec4IEpB7aAuyH/GtE7E0gLpcqnC72r55krEIVILfqIR2Mlr5OKUzyeoCyWAU/yw==} + engines: {node: '>=8'} + dependencies: + '@sentry/types': 7.76.0 + dev: false + /@sentry/utils@7.77.0: resolution: {integrity: sha512-NmM2kDOqVchrey3N5WSzdQoCsyDkQkiRxExPaNI2oKQ/jMWHs9yt0tSy7otPBcXs0AP59ihl75Bvm1tDRcsp5g==} engines: {node: '>=8'} @@ -8921,6 +9055,10 @@ packages: resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} dev: true + /@sinclair/typebox@0.32.5: + resolution: {integrity: sha512-0M6FyxZwIEu/Ly6W+l7iYqiZQYJ8khLOJGzg+cxivNKRKqk9hctcuDC0UYI7B9vNgycExA8w40m4M3yDKW37RA==} + dev: false + /@sindresorhus/is@5.6.0: resolution: {integrity: sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g==} engines: {node: '>=14.16'} @@ -9547,6 +9685,12 @@ packages: resolution: {integrity: sha512-8e2HYcg7ohnTUbHk8focoklEQYvemQmu9M/f43DZVx43kHn0tE3BY/6gSDxS7k0SprtS0NHvj+L80cGLnoOUcQ==} dev: true + /@types/nodemailer@6.4.14: + resolution: {integrity: sha512-fUWthHO9k9DSdPCSPRqcu6TWhYyxTBg382vlNIttSe9M7XfsT06y0f24KHXtbnijPGGRIcVvdKHTNikOI6qiHA==} + dependencies: + '@types/node': 20.11.26 + dev: true + /@types/nodemailer@6.4.8: resolution: {integrity: sha512-oVsJSCkqViCn8/pEu2hfjwVO+Gb3e+eTWjg3PcjeFKRItfKpKwHphQqbYmPQrlMk+op7pNNWPbsJIEthpFN/OQ==} dependencies: @@ -11458,6 +11602,10 @@ packages: resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==} engines: {node: '>=8'} + /bintrees@1.0.2: + resolution: {integrity: sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==} + dev: false + /bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} dependencies: @@ -14931,6 +15079,11 @@ packages: resolution: {integrity: sha512-KZFBHenkVuyyG4uaqRSXqWJr3HTxcaPguM7rU1BlH/mtbDlzaXNSXTa9AhV+fXEjrNemHu9vtLRIaM8/8OW0xA==} dev: true + /hono@4.0.5: + resolution: {integrity: sha512-6LEGL1Pf3+dLjVA0NJxAB/3FJ6S3W5qxd/XOG7Wl9YOrpMRZT9lt83R4Ojs8dO6GbAUSutI7zTyjStnSn9sbEg==} + engines: {node: '>=16.0.0'} + dev: false + /hosted-git-info@2.8.9: resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==} @@ -16495,6 +16648,11 @@ packages: engines: {node: '>=18'} dev: false + /ky@1.2.0: + resolution: {integrity: sha512-dnPW+T78MuJ9tLAiF/apJV7bP7RRRCARXQxsCmsWiKLXqGtMBOgDVOFRYzCAfNe/OrRyFyor5ESgvvC+QWEqOA==} + engines: {node: '>=18'} + dev: false + /language-subtag-registry@0.3.22: resolution: {integrity: sha512-tN0MCzyWnoz/4nHS6uxdlFWoUZT7ABptwKPQ52Ea7URk6vll88bWBVhodtnlfEuCcKWNGoc+uGbw1cwa9IKh/w==} dev: false @@ -18203,7 +18361,7 @@ packages: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} dev: false - /next-auth@4.22.1(next@14.1.0)(nodemailer@6.9.3)(react-dom@18.2.0)(react@18.2.0): + /next-auth@4.22.1(next@14.1.0)(nodemailer@6.9.8)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-NTR3f6W7/AWXKw8GSsgSyQcDW6jkslZLH8AiZa5PQ09w1kR8uHtR9rez/E9gAq/o17+p0JYHE8QjF3RoniiObA==} peerDependencies: next: ^12.2.5 || ^13 @@ -18219,7 +18377,7 @@ packages: cookie: 0.5.0 jose: 4.15.5 next: 14.1.0(@babel/core@7.22.9)(react-dom@18.2.0)(react@18.2.0) - nodemailer: 6.9.3 + nodemailer: 6.9.8 oauth: 0.9.15 openid-client: 5.6.5 preact: 10.19.6 @@ -18388,8 +18546,8 @@ packages: /node-releases@2.0.14: resolution: {integrity: sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==} - /nodemailer@6.9.3: - resolution: {integrity: sha512-fy9v3NgTzBngrMFkDsKEj0r02U7jm6XfC3b52eoNV+GCrGj+s8pt5OqhiJdWKuw51zCTdiNR/IUD1z33LIIGpg==} + /nodemailer@6.9.8: + resolution: {integrity: sha512-cfrYUk16e67Ks051i4CntM9kshRYei1/o/Gi8K1d+R34OIs21xdFnW7Pt7EucmVKA0LKtqUGNcjMZ7ehjl49mQ==} engines: {node: '>=6.0.0'} /nopt@7.2.0: @@ -19536,6 +19694,14 @@ packages: engines: {node: '>=0.4.0'} dev: false + /prom-client@15.1.0: + resolution: {integrity: sha512-cCD7jLTqyPdjEPBo/Xk4Iu8jxjuZgZJ3e/oET3L+ZwOuap/7Cw3dH/TJSsZKs1TQLZ2IHpIlRAKw82ef06kmMw==} + engines: {node: ^16 || ^18 || >=20} + dependencies: + '@opentelemetry/api': 1.8.0 + tdigest: 0.1.2 + dev: false + /promise.series@0.2.0: resolution: {integrity: sha512-VWQJyU2bcDTgZw8kpfBpB/ejZASlCrzwz5f2hjb/zlujOEB4oeiAhHygAWq8ubsX2GVkD4kCU5V2dwOTaCY5EQ==} engines: {node: '>=0.12'} @@ -21535,6 +21701,12 @@ packages: yallist: 4.0.0 dev: true + /tdigest@0.1.2: + resolution: {integrity: sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==} + dependencies: + bintrees: 1.0.2 + dev: false + /terser-webpack-plugin@5.3.10(@swc/core@1.3.101)(esbuild@0.19.11)(webpack@5.90.3): resolution: {integrity: sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==} engines: {node: '>= 10.13.0'} @@ -21664,6 +21836,15 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + /toucan-js@3.3.1: + resolution: {integrity: sha512-9BpkHb/Pzsrtl1ItNq9OEQPnuUHwzce0nV2uG+DYFiQ4fPyiA6mKTBcDwQzcvNkfSER038U+8TzvdkCev+Maww==} + dependencies: + '@sentry/core': 7.76.0 + '@sentry/integrations': 7.76.0 + '@sentry/types': 7.76.0 + '@sentry/utils': 7.76.0 + dev: false + /tough-cookie@4.1.3: resolution: {integrity: sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw==} engines: {node: '>=6'}