⚡ Introduce a new high-performing standalone chat API (#1200)
Closes #1154 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Added authentication functionality for user sessions in chat API. - Introduced chat-related API endpoints for starting, previewing, and continuing chat sessions, and streaming messages. - Implemented WhatsApp API webhook handling for receiving and processing messages. - Added environment variable `NEXT_PUBLIC_CHAT_API_URL` for chat API URL configuration. - **Bug Fixes** - Adjusted file upload logic to correctly determine the API host. - Fixed message streaming URL in chat integration with OpenAI. - **Documentation** - Updated guides for creating blocks, local installation, self-hosting, and deployment to use `bun` instead of `pnpm`. - **Refactor** - Refactored chat API functionalities to use modular architecture. - Simplified client log saving and session update functionalities by using external functions. - Transitioned package management and workflow commands to use `bun`. - **Chores** - Switched to `bun` for package management in Dockerfiles and GitHub workflows. - Added new Dockerfile for chat API service setup with Bun framework. - Updated `.prettierignore` and documentation with new commands. - **Style** - No visible changes to end-users. - **Tests** - No visible changes to end-users. - **Revert** - No reverts in this release. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
102
packages/bot-engine/apiHandlers/continueChat.ts
Normal file
102
packages/bot-engine/apiHandlers/continueChat.ts
Normal file
@ -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,
|
||||
}
|
||||
}
|
130
packages/bot-engine/apiHandlers/getMessageStream.ts
Normal file
130
packages/bot-engine/apiHandlers/getMessageStream.ts
Normal file
@ -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',
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
35
packages/bot-engine/apiHandlers/receiveMessage.ts
Normal file
35
packages/bot-engine/apiHandlers/receiveMessage.ts
Normal file
@ -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,
|
||||
},
|
||||
})
|
||||
}
|
31
packages/bot-engine/apiHandlers/receiveMessagePreview.ts
Normal file
31
packages/bot-engine/apiHandlers/receiveMessagePreview.ts
Normal file
@ -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,
|
||||
},
|
||||
})
|
||||
}
|
49
packages/bot-engine/apiHandlers/saveClientLogs.ts
Normal file
49
packages/bot-engine/apiHandlers/saveClientLogs.ts
Normal file
@ -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.',
|
||||
})
|
||||
}
|
||||
}
|
107
packages/bot-engine/apiHandlers/startChat.ts
Normal file
107
packages/bot-engine/apiHandlers/startChat.ts
Normal file
@ -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<string, unknown>
|
||||
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,
|
||||
}
|
||||
}
|
97
packages/bot-engine/apiHandlers/startChatPreview.ts
Normal file
97
packages/bot-engine/apiHandlers/startChatPreview.ts
Normal file
@ -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<string, unknown>
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
97
packages/bot-engine/apiHandlers/updateTypebotInSession.ts
Normal file
97
packages/bot-engine/apiHandlers/updateTypebotInSession.ts
Normal file
@ -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<PublicTypebot, 'edges' | 'variables' | 'groups'> | 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<PublicTypebot, 'edges' | 'variables' | 'groups'>
|
||||
): 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
|
||||
)
|
||||
),
|
||||
]
|
@ -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: [
|
||||
|
@ -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 {
|
||||
|
@ -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,
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
6
packages/bot-engine/queries/getCredentials.ts
Normal file
6
packages/bot-engine/queries/getCredentials.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import prisma from '@typebot.io/lib/prisma'
|
||||
|
||||
export const getCredentials = async (credentialsId: string) =>
|
||||
prisma.credentials.findUnique({
|
||||
where: { id: credentialsId },
|
||||
})
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
Reference in New Issue
Block a user