@@ -0,0 +1,87 @@
|
||||
import { ChoiceInputBlock, SessionState } from '@typebot.io/schemas'
|
||||
import { injectVariableValuesInButtonsInputBlock } from './injectVariableValuesInButtonsInputBlock'
|
||||
import { ParsedReply } from '@/features/chat/types'
|
||||
|
||||
export const parseButtonsReply =
|
||||
(state: SessionState) =>
|
||||
(inputValue: string, block: ChoiceInputBlock): ParsedReply => {
|
||||
const displayedItems =
|
||||
injectVariableValuesInButtonsInputBlock(state)(block).items
|
||||
if (block.options.isMultipleChoice) {
|
||||
const longestItemsFirst = [...displayedItems].sort(
|
||||
(a, b) => (b.content?.length ?? 0) - (a.content?.length ?? 0)
|
||||
)
|
||||
const matchedItemsByContent = longestItemsFirst.reduce<{
|
||||
strippedInput: string
|
||||
matchedItemIds: string[]
|
||||
}>(
|
||||
(acc, item) => {
|
||||
if (
|
||||
item.content &&
|
||||
acc.strippedInput.toLowerCase().includes(item.content.toLowerCase())
|
||||
)
|
||||
return {
|
||||
strippedInput: acc.strippedInput.replace(item.content ?? '', ''),
|
||||
matchedItemIds: [...acc.matchedItemIds, item.id],
|
||||
}
|
||||
return acc
|
||||
},
|
||||
{
|
||||
strippedInput: inputValue.trim(),
|
||||
matchedItemIds: [],
|
||||
}
|
||||
)
|
||||
const remainingItems = displayedItems.filter(
|
||||
(item) => !matchedItemsByContent.matchedItemIds.includes(item.id)
|
||||
)
|
||||
const matchedItemsByIndex = remainingItems.reduce<{
|
||||
strippedInput: string
|
||||
matchedItemIds: string[]
|
||||
}>(
|
||||
(acc, item, idx) => {
|
||||
if (acc.strippedInput.includes(`${idx + 1}`))
|
||||
return {
|
||||
strippedInput: acc.strippedInput.replace(`${idx + 1}`, ''),
|
||||
matchedItemIds: [...acc.matchedItemIds, item.id],
|
||||
}
|
||||
return acc
|
||||
},
|
||||
{
|
||||
strippedInput: matchedItemsByContent.strippedInput,
|
||||
matchedItemIds: [],
|
||||
}
|
||||
)
|
||||
const matchedItems = displayedItems.filter((item) =>
|
||||
[
|
||||
...matchedItemsByContent.matchedItemIds,
|
||||
...matchedItemsByIndex.matchedItemIds,
|
||||
].includes(item.id)
|
||||
)
|
||||
if (matchedItems.length === 0) return { status: 'fail' }
|
||||
return {
|
||||
status: 'success',
|
||||
reply: matchedItems.map((item) => item.content).join(', '),
|
||||
}
|
||||
}
|
||||
if (state.whatsApp) {
|
||||
const matchedItem = displayedItems.find((item) => item.id === inputValue)
|
||||
if (!matchedItem) return { status: 'fail' }
|
||||
return {
|
||||
status: 'success',
|
||||
reply: matchedItem.content ?? '',
|
||||
}
|
||||
}
|
||||
const longestItemsFirst = [...displayedItems].sort(
|
||||
(a, b) => (b.content?.length ?? 0) - (a.content?.length ?? 0)
|
||||
)
|
||||
const matchedItem = longestItemsFirst.find(
|
||||
(item) =>
|
||||
item.content &&
|
||||
inputValue.toLowerCase().trim() === item.content.toLowerCase().trim()
|
||||
)
|
||||
if (!matchedItem) return { status: 'fail' }
|
||||
return {
|
||||
status: 'success',
|
||||
reply: matchedItem.content ?? '',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { ParsedReply } from '@/features/chat/types'
|
||||
import { DateInputBlock } from '@typebot.io/schemas'
|
||||
import { parse as chronoParse } from 'chrono-node'
|
||||
|
||||
export const parseDateReply = (
|
||||
reply: string,
|
||||
block: DateInputBlock
|
||||
): ParsedReply => {
|
||||
const parsedDate = chronoParse(reply)
|
||||
if (parsedDate.length === 0) return { status: 'fail' }
|
||||
const formatOptions: Intl.DateTimeFormatOptions = {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: block.options.hasTime ? '2-digit' : undefined,
|
||||
minute: block.options.hasTime ? '2-digit' : undefined,
|
||||
}
|
||||
const startDate = parsedDate[0].start
|
||||
.date()
|
||||
.toLocaleString(undefined, formatOptions)
|
||||
const endDate = parsedDate[0].end
|
||||
?.date()
|
||||
.toLocaleString(undefined, formatOptions)
|
||||
return {
|
||||
status: 'success',
|
||||
reply: block.options.isRange ? `${startDate} to ${endDate}` : startDate,
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
export const parseReadableDate = ({
|
||||
from,
|
||||
to,
|
||||
hasTime,
|
||||
isRange,
|
||||
}: {
|
||||
from: string
|
||||
to: string
|
||||
hasTime?: boolean
|
||||
isRange?: boolean
|
||||
}) => {
|
||||
const currentLocale = window.navigator.language
|
||||
const formatOptions: Intl.DateTimeFormatOptions = {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: hasTime ? '2-digit' : undefined,
|
||||
minute: hasTime ? '2-digit' : undefined,
|
||||
}
|
||||
const fromReadable = new Date(from).toLocaleString(
|
||||
currentLocale,
|
||||
formatOptions
|
||||
)
|
||||
const toReadable = new Date(to).toLocaleString(currentLocale, formatOptions)
|
||||
return `${fromReadable}${isRange ? ` to ${toReadable}` : ''}`
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from '@typebot.io/schemas'
|
||||
import { byId, isDefined } from '@typebot.io/lib'
|
||||
import { z } from 'zod'
|
||||
import { generatePresignedUrl } from '@typebot.io/lib/api/storage'
|
||||
import { generatePresignedUrl } from '@typebot.io/lib/api/generatePresignedUrl'
|
||||
import { env } from '@typebot.io/env'
|
||||
|
||||
export const getUploadUrl = publicProcedure
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const validateNumber = (inputValue: string) => !isNaN(Number(inputValue))
|
||||
@@ -1,4 +1,16 @@
|
||||
import { parsePhoneNumber } from 'libphonenumber-js'
|
||||
import {
|
||||
CountryCode,
|
||||
findPhoneNumbersInText,
|
||||
isSupportedCountry,
|
||||
} from 'libphonenumber-js'
|
||||
|
||||
export const formatPhoneNumber = (phoneNumber: string) =>
|
||||
parsePhoneNumber(phoneNumber).formatInternational().replaceAll(' ', '')
|
||||
export const formatPhoneNumber = (
|
||||
phoneNumber: string,
|
||||
defaultCountryCode?: string
|
||||
) =>
|
||||
findPhoneNumbersInText(
|
||||
phoneNumber,
|
||||
defaultCountryCode && isSupportedCountry(defaultCountryCode)
|
||||
? (defaultCountryCode as CountryCode)
|
||||
: undefined
|
||||
).at(0)?.number.number
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
import { isValidPhoneNumber } from 'libphonenumber-js'
|
||||
|
||||
export const validatePhoneNumber = (phoneNumber: string) =>
|
||||
isValidPhoneNumber(phoneNumber)
|
||||
@@ -0,0 +1,95 @@
|
||||
import { PictureChoiceBlock, SessionState } from '@typebot.io/schemas'
|
||||
import { ParsedReply } from '@/features/chat/types'
|
||||
import { injectVariableValuesInPictureChoiceBlock } from './injectVariableValuesInPictureChoiceBlock'
|
||||
|
||||
export const parsePictureChoicesReply =
|
||||
(state: SessionState) =>
|
||||
(inputValue: string, block: PictureChoiceBlock): ParsedReply => {
|
||||
const displayedItems = injectVariableValuesInPictureChoiceBlock(
|
||||
state.typebotsQueue[0].typebot.variables
|
||||
)(block).items
|
||||
if (block.options.isMultipleChoice) {
|
||||
const longestItemsFirst = [...displayedItems].sort(
|
||||
(a, b) => (b.title?.length ?? 0) - (a.title?.length ?? 0)
|
||||
)
|
||||
const matchedItemsByContent = longestItemsFirst.reduce<{
|
||||
strippedInput: string
|
||||
matchedItemIds: string[]
|
||||
}>(
|
||||
(acc, item) => {
|
||||
if (
|
||||
item.title &&
|
||||
acc.strippedInput.toLowerCase().includes(item.title.toLowerCase())
|
||||
)
|
||||
return {
|
||||
strippedInput: acc.strippedInput.replace(item.title ?? '', ''),
|
||||
matchedItemIds: [...acc.matchedItemIds, item.id],
|
||||
}
|
||||
return acc
|
||||
},
|
||||
{
|
||||
strippedInput: inputValue.trim(),
|
||||
matchedItemIds: [],
|
||||
}
|
||||
)
|
||||
const remainingItems = displayedItems.filter(
|
||||
(item) => !matchedItemsByContent.matchedItemIds.includes(item.id)
|
||||
)
|
||||
const matchedItemsByIndex = remainingItems.reduce<{
|
||||
strippedInput: string
|
||||
matchedItemIds: string[]
|
||||
}>(
|
||||
(acc, item, idx) => {
|
||||
if (acc.strippedInput.includes(`${idx + 1}`))
|
||||
return {
|
||||
strippedInput: acc.strippedInput.replace(`${idx + 1}`, ''),
|
||||
matchedItemIds: [...acc.matchedItemIds, item.id],
|
||||
}
|
||||
return acc
|
||||
},
|
||||
{
|
||||
strippedInput: matchedItemsByContent.strippedInput,
|
||||
matchedItemIds: [],
|
||||
}
|
||||
)
|
||||
|
||||
const matchedItems = displayedItems.filter((item) =>
|
||||
[
|
||||
...matchedItemsByContent.matchedItemIds,
|
||||
...matchedItemsByIndex.matchedItemIds,
|
||||
].includes(item.id)
|
||||
)
|
||||
|
||||
if (matchedItems.length === 0) return { status: 'fail' }
|
||||
return {
|
||||
status: 'success',
|
||||
reply: matchedItems
|
||||
.map((item) => item.title ?? item.pictureSrc ?? '')
|
||||
.join(', '),
|
||||
}
|
||||
}
|
||||
if (state.whatsApp) {
|
||||
const matchedItem = displayedItems.find((item) => item.id === inputValue)
|
||||
if (!matchedItem) return { status: 'fail' }
|
||||
return {
|
||||
status: 'success',
|
||||
reply: matchedItem.title ?? matchedItem.pictureSrc ?? '',
|
||||
}
|
||||
}
|
||||
const longestItemsFirst = [...displayedItems].sort(
|
||||
(a, b) => (b.title?.length ?? 0) - (a.title?.length ?? 0)
|
||||
)
|
||||
const matchedItem = longestItemsFirst.find(
|
||||
(item) =>
|
||||
item.title &&
|
||||
item.title
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.includes(inputValue.toLowerCase().trim())
|
||||
)
|
||||
if (!matchedItem) return { status: 'fail' }
|
||||
return {
|
||||
status: 'success',
|
||||
reply: matchedItem.title ?? matchedItem.pictureSrc ?? '',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { RatingInputBlock } from '@typebot.io/schemas'
|
||||
|
||||
export const validateRatingReply = (reply: string, block: RatingInputBlock) =>
|
||||
Number(reply) <= block.options.length
|
||||
@@ -74,6 +74,7 @@ export const executeChatwootBlock = (
|
||||
state: SessionState,
|
||||
block: ChatwootBlock
|
||||
): ExecuteIntegrationResponse => {
|
||||
if (state.whatsApp) return { outgoingEdgeId: block.outgoingEdgeId }
|
||||
const { typebot, resultId } = state.typebotsQueue[0]
|
||||
const chatwootCode =
|
||||
block.options.task === 'Close widget'
|
||||
|
||||
@@ -7,7 +7,8 @@ export const executeGoogleAnalyticsBlock = (
|
||||
block: GoogleAnalyticsBlock
|
||||
): ExecuteIntegrationResponse => {
|
||||
const { typebot, resultId } = state.typebotsQueue[0]
|
||||
if (!resultId) return { outgoingEdgeId: block.outgoingEdgeId }
|
||||
if (!resultId || state.whatsApp)
|
||||
return { outgoingEdgeId: block.outgoingEdgeId }
|
||||
const googleAnalytics = deepParseVariables(typebot.variables, {
|
||||
guessCorrectTypes: true,
|
||||
removeEmptyStrings: true,
|
||||
|
||||
@@ -72,7 +72,8 @@ export const createChatCompletionOpenAI = async (
|
||||
if (
|
||||
isPlaneteScale() &&
|
||||
isCredentialsV2(credentials) &&
|
||||
newSessionState.isStreamEnabled
|
||||
newSessionState.isStreamEnabled &&
|
||||
!newSessionState.whatsApp
|
||||
) {
|
||||
const assistantMessageVariableName = typebot.variables.find(
|
||||
(variable) =>
|
||||
|
||||
@@ -7,7 +7,12 @@ export const executePixelBlock = (
|
||||
block: PixelBlock
|
||||
): ExecuteIntegrationResponse => {
|
||||
const { typebot, resultId } = state.typebotsQueue[0]
|
||||
if (!resultId || !block.options.pixelId || !block.options.eventType)
|
||||
if (
|
||||
!resultId ||
|
||||
!block.options.pixelId ||
|
||||
!block.options.eventType ||
|
||||
state.whatsApp
|
||||
)
|
||||
return { outgoingEdgeId: block.outgoingEdgeId }
|
||||
const pixel = deepParseVariables(typebot.variables, {
|
||||
guessCorrectTypes: true,
|
||||
|
||||
@@ -58,7 +58,7 @@ export const executeWebhookBlock = async (
|
||||
})
|
||||
return { outgoingEdgeId: block.outgoingEdgeId, logs }
|
||||
}
|
||||
if (block.options.isExecutedOnClient)
|
||||
if (block.options.isExecutedOnClient && !state.whatsApp)
|
||||
return {
|
||||
outgoingEdgeId: block.outgoingEdgeId,
|
||||
clientSideActions: [
|
||||
|
||||
@@ -9,7 +9,8 @@ export const executeScript = (
|
||||
block: ScriptBlock
|
||||
): ExecuteLogicResponse => {
|
||||
const { variables } = state.typebotsQueue[0].typebot
|
||||
if (!block.options.content) return { outgoingEdgeId: block.outgoingEdgeId }
|
||||
if (!block.options.content || state.whatsApp)
|
||||
return { outgoingEdgeId: block.outgoingEdgeId }
|
||||
|
||||
const scriptToExecute = parseScriptToExecuteClientSideAction(
|
||||
variables,
|
||||
|
||||
@@ -15,12 +15,11 @@ export const executeSetVariable = (
|
||||
return {
|
||||
outgoingEdgeId: block.outgoingEdgeId,
|
||||
}
|
||||
const expressionToEvaluate = getExpressionToEvaluate(
|
||||
state.typebotsQueue[0].resultId
|
||||
)(block.options)
|
||||
const expressionToEvaluate = getExpressionToEvaluate(state)(block.options)
|
||||
const isCustomValue = !block.options.type || block.options.type === 'Custom'
|
||||
if (
|
||||
expressionToEvaluate &&
|
||||
!state.whatsApp &&
|
||||
((isCustomValue && block.options.isExecutedOnClient) ||
|
||||
block.options.type === 'Moment of the day')
|
||||
) {
|
||||
@@ -73,9 +72,13 @@ const evaluateSetVariableExpression =
|
||||
}
|
||||
|
||||
const getExpressionToEvaluate =
|
||||
(resultId: string | undefined) =>
|
||||
(state: SessionState) =>
|
||||
(options: SetVariableBlock['options']): string | null => {
|
||||
switch (options.type) {
|
||||
case 'Contact name':
|
||||
return state.whatsApp?.contact.name ?? ''
|
||||
case 'Phone number':
|
||||
return state.whatsApp?.contact.phoneNumber ?? ''
|
||||
case 'Now':
|
||||
case 'Today':
|
||||
return 'new Date().toISOString()'
|
||||
@@ -89,7 +92,10 @@ const getExpressionToEvaluate =
|
||||
return 'Math.random().toString(36).substring(2, 15)'
|
||||
}
|
||||
case 'User ID': {
|
||||
return resultId ?? 'Math.random().toString(36).substring(2, 15)'
|
||||
return (
|
||||
state.typebotsQueue[0].resultId ??
|
||||
'Math.random().toString(36).substring(2, 15)'
|
||||
)
|
||||
}
|
||||
case 'Map item with same index': {
|
||||
return `const itemIndex = ${options.mapListItemParams?.baseListVariableId}.indexOf(${options.mapListItemParams?.baseItemVariableId})
|
||||
|
||||
@@ -1,36 +1,14 @@
|
||||
import { publicProcedure } from '@/helpers/server/trpc'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import {
|
||||
ChatReply,
|
||||
chatReplySchema,
|
||||
GoogleAnalyticsBlock,
|
||||
IntegrationBlockType,
|
||||
PixelBlock,
|
||||
ReplyLog,
|
||||
sendMessageInputSchema,
|
||||
SessionState,
|
||||
StartParams,
|
||||
StartTypebot,
|
||||
startTypebotSchema,
|
||||
Theme,
|
||||
Variable,
|
||||
VariableWithValue,
|
||||
} from '@typebot.io/schemas'
|
||||
import { isDefined, isNotEmpty, omit } from '@typebot.io/lib'
|
||||
import { prefillVariables } from '@/features/variables/prefillVariables'
|
||||
import { injectVariablesFromExistingResult } from '@/features/variables/injectVariablesFromExistingResult'
|
||||
import { deepParseVariables } from '@/features/variables/deepParseVariable'
|
||||
import { parseVariables } from '@/features/variables/parseVariables'
|
||||
import { NodeType, parse } from 'node-html-parser'
|
||||
import { saveStateToDatabase } from '../helpers/saveStateToDatabase'
|
||||
import { getSession } from '../queries/getSession'
|
||||
import { continueBotFlow } from '../helpers/continueBotFlow'
|
||||
import { startBotFlow } from '../helpers/startBotFlow'
|
||||
import { findTypebot } from '../queries/findTypebot'
|
||||
import { findPublicTypebot } from '../queries/findPublicTypebot'
|
||||
import { findResult } from '../queries/findResult'
|
||||
import { createId } from '@paralleldrive/cuid2'
|
||||
import { env } from '@typebot.io/env'
|
||||
import { parseDynamicTheme } from '../helpers/parseDynamicTheme'
|
||||
import { startSession } from '../helpers/startSession'
|
||||
import { restartSession } from '../queries/restartSession'
|
||||
import {
|
||||
chatReplySchema,
|
||||
sendMessageInputSchema,
|
||||
} from '@typebot.io/schemas/features/chat/schema'
|
||||
|
||||
export const sendMessage = publicProcedure
|
||||
.meta({
|
||||
@@ -53,7 +31,6 @@ export const sendMessage = publicProcedure
|
||||
|
||||
if (!session) {
|
||||
const {
|
||||
sessionId,
|
||||
typebot,
|
||||
messages,
|
||||
input,
|
||||
@@ -61,9 +38,27 @@ export const sendMessage = publicProcedure
|
||||
dynamicTheme,
|
||||
logs,
|
||||
clientSideActions,
|
||||
} = await startSession(startParams, user?.id, clientLogs)
|
||||
newSessionState,
|
||||
} = await startSession(startParams, user?.id)
|
||||
|
||||
const allLogs = clientLogs ? [...(logs ?? []), ...clientLogs] : logs
|
||||
|
||||
const session = startParams?.isOnlyRegistering
|
||||
? await restartSession({
|
||||
state: newSessionState,
|
||||
})
|
||||
: await saveStateToDatabase({
|
||||
isFirstSave: true,
|
||||
session: {
|
||||
state: newSessionState,
|
||||
},
|
||||
input,
|
||||
logs: allLogs,
|
||||
clientSideActions,
|
||||
})
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
sessionId: session.id,
|
||||
typebot: typebot
|
||||
? {
|
||||
id: typebot.id,
|
||||
@@ -105,349 +100,10 @@ export const sendMessage = publicProcedure
|
||||
messages,
|
||||
input,
|
||||
clientSideActions,
|
||||
dynamicTheme: parseDynamicThemeReply(newSessionState),
|
||||
dynamicTheme: parseDynamicTheme(newSessionState),
|
||||
logs,
|
||||
lastMessageNewFormat,
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const startSession = async (
|
||||
startParams?: StartParams,
|
||||
userId?: string,
|
||||
clientLogs?: ReplyLog[]
|
||||
) => {
|
||||
if (!startParams)
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'StartParams are missing',
|
||||
})
|
||||
|
||||
const typebot = await getTypebot(startParams, userId)
|
||||
|
||||
const prefilledVariables = startParams.prefilledVariables
|
||||
? prefillVariables(typebot.variables, startParams.prefilledVariables)
|
||||
: typebot.variables
|
||||
|
||||
const result = await getResult({
|
||||
...startParams,
|
||||
isPreview: startParams.isPreview || typeof startParams.typebot !== 'string',
|
||||
typebotId: typebot.id,
|
||||
prefilledVariables,
|
||||
isRememberUserEnabled:
|
||||
typebot.settings.general.rememberUser?.isEnabled ??
|
||||
(isDefined(typebot.settings.general.isNewResultOnRefreshEnabled)
|
||||
? !typebot.settings.general.isNewResultOnRefreshEnabled
|
||||
: false),
|
||||
})
|
||||
|
||||
const startVariables =
|
||||
result && result.variables.length > 0
|
||||
? injectVariablesFromExistingResult(prefilledVariables, result.variables)
|
||||
: prefilledVariables
|
||||
|
||||
const initialState: SessionState = {
|
||||
version: '2',
|
||||
typebotsQueue: [
|
||||
{
|
||||
resultId: result?.id,
|
||||
typebot: {
|
||||
version: typebot.version,
|
||||
id: typebot.id,
|
||||
groups: typebot.groups,
|
||||
edges: typebot.edges,
|
||||
variables: startVariables,
|
||||
},
|
||||
answers: [],
|
||||
},
|
||||
],
|
||||
dynamicTheme: parseDynamicThemeInState(typebot.theme),
|
||||
isStreamEnabled: startParams.isStreamEnabled,
|
||||
}
|
||||
|
||||
const { messages, input, clientSideActions, newSessionState, logs } =
|
||||
await startBotFlow(initialState, startParams.startGroupId)
|
||||
|
||||
const clientSideActionsNeedSessionId = clientSideActions?.some(
|
||||
(action) =>
|
||||
'setVariable' in action || 'streamOpenAiChatCompletion' in action
|
||||
)
|
||||
|
||||
const startClientSideAction = clientSideActions ?? []
|
||||
|
||||
const parsedStartPropsActions = parseStartClientSideAction(typebot)
|
||||
|
||||
const startLogs = logs ?? []
|
||||
|
||||
if (isDefined(parsedStartPropsActions)) {
|
||||
if (!result) {
|
||||
if ('startPropsToInject' in parsedStartPropsActions) {
|
||||
const { customHeadCode, googleAnalyticsId, pixelId, gtmId } =
|
||||
parsedStartPropsActions.startPropsToInject
|
||||
let toolsList = ''
|
||||
if (customHeadCode) toolsList += 'Custom head code, '
|
||||
if (googleAnalyticsId) toolsList += 'Google Analytics, '
|
||||
if (pixelId) toolsList += 'Pixel, '
|
||||
if (gtmId) toolsList += 'Google Tag Manager, '
|
||||
toolsList = toolsList.slice(0, -2)
|
||||
startLogs.push({
|
||||
description: `${toolsList} ${
|
||||
toolsList.includes(',') ? 'are not' : 'is not'
|
||||
} enabled in Preview mode`,
|
||||
status: 'info',
|
||||
})
|
||||
}
|
||||
} else {
|
||||
startClientSideAction.push(parsedStartPropsActions)
|
||||
}
|
||||
}
|
||||
|
||||
if (!input && !clientSideActionsNeedSessionId)
|
||||
return {
|
||||
messages,
|
||||
clientSideActions:
|
||||
startClientSideAction.length > 0 ? startClientSideAction : undefined,
|
||||
typebot: {
|
||||
id: typebot.id,
|
||||
settings: deepParseVariables(
|
||||
newSessionState.typebotsQueue[0].typebot.variables
|
||||
)(typebot.settings),
|
||||
theme: deepParseVariables(
|
||||
newSessionState.typebotsQueue[0].typebot.variables
|
||||
)(typebot.theme),
|
||||
},
|
||||
dynamicTheme: parseDynamicThemeReply(newSessionState),
|
||||
logs: startLogs.length > 0 ? startLogs : undefined,
|
||||
}
|
||||
|
||||
const allLogs = clientLogs ? [...(logs ?? []), ...clientLogs] : logs
|
||||
|
||||
const session = await saveStateToDatabase({
|
||||
session: {
|
||||
state: newSessionState,
|
||||
},
|
||||
input,
|
||||
logs: allLogs,
|
||||
clientSideActions,
|
||||
})
|
||||
|
||||
return {
|
||||
resultId: result?.id,
|
||||
sessionId: session.id,
|
||||
typebot: {
|
||||
id: typebot.id,
|
||||
settings: deepParseVariables(
|
||||
newSessionState.typebotsQueue[0].typebot.variables
|
||||
)(typebot.settings),
|
||||
theme: deepParseVariables(
|
||||
newSessionState.typebotsQueue[0].typebot.variables
|
||||
)(typebot.theme),
|
||||
},
|
||||
messages,
|
||||
input,
|
||||
clientSideActions:
|
||||
startClientSideAction.length > 0 ? startClientSideAction : undefined,
|
||||
dynamicTheme: parseDynamicThemeReply(newSessionState),
|
||||
logs: startLogs.length > 0 ? startLogs : undefined,
|
||||
} satisfies ChatReply
|
||||
}
|
||||
|
||||
const getTypebot = async (
|
||||
{ typebot, isPreview }: Pick<StartParams, 'typebot' | 'isPreview'>,
|
||||
userId?: string
|
||||
): Promise<StartTypebot> => {
|
||||
if (typeof typebot !== 'string') return typebot
|
||||
if (isPreview && !userId && !env.NEXT_PUBLIC_E2E_TEST)
|
||||
throw new TRPCError({
|
||||
code: 'UNAUTHORIZED',
|
||||
message:
|
||||
'You need to authenticate the request to start a bot in preview mode.',
|
||||
})
|
||||
const typebotQuery = isPreview
|
||||
? await findTypebot({ id: typebot, userId })
|
||||
: await findPublicTypebot({ publicId: typebot })
|
||||
|
||||
const parsedTypebot =
|
||||
typebotQuery && 'typebot' in typebotQuery
|
||||
? {
|
||||
id: typebotQuery.typebotId,
|
||||
...omit(typebotQuery.typebot, 'workspace'),
|
||||
...omit(typebotQuery, 'typebot', 'typebotId'),
|
||||
}
|
||||
: typebotQuery
|
||||
|
||||
if (!parsedTypebot || parsedTypebot.isArchived)
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Typebot not found',
|
||||
})
|
||||
|
||||
const isQuarantinedOrSuspended =
|
||||
typebotQuery &&
|
||||
'typebot' in typebotQuery &&
|
||||
(typebotQuery.typebot.workspace.isQuarantined ||
|
||||
typebotQuery.typebot.workspace.isSuspended)
|
||||
|
||||
if (
|
||||
('isClosed' in parsedTypebot && parsedTypebot.isClosed) ||
|
||||
isQuarantinedOrSuspended
|
||||
)
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Typebot is closed',
|
||||
})
|
||||
|
||||
return startTypebotSchema.parse(parsedTypebot)
|
||||
}
|
||||
|
||||
const getResult = async ({
|
||||
isPreview,
|
||||
resultId,
|
||||
prefilledVariables,
|
||||
isRememberUserEnabled,
|
||||
}: Pick<StartParams, 'isPreview' | 'resultId'> & {
|
||||
typebotId: string
|
||||
prefilledVariables: Variable[]
|
||||
isRememberUserEnabled: boolean
|
||||
}) => {
|
||||
if (isPreview) return
|
||||
const existingResult =
|
||||
resultId && isRememberUserEnabled
|
||||
? await findResult({ id: resultId })
|
||||
: undefined
|
||||
|
||||
const prefilledVariableWithValue = prefilledVariables.filter(
|
||||
(prefilledVariable) => isDefined(prefilledVariable.value)
|
||||
)
|
||||
|
||||
const updatedResult = {
|
||||
variables: prefilledVariableWithValue.concat(
|
||||
existingResult?.variables.filter(
|
||||
(resultVariable) =>
|
||||
isDefined(resultVariable.value) &&
|
||||
!prefilledVariableWithValue.some(
|
||||
(prefilledVariable) =>
|
||||
prefilledVariable.name === resultVariable.name
|
||||
)
|
||||
) ?? []
|
||||
) as VariableWithValue[],
|
||||
}
|
||||
return {
|
||||
id: existingResult?.id ?? createId(),
|
||||
variables: updatedResult.variables,
|
||||
answers: existingResult?.answers ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
const parseDynamicThemeInState = (theme: Theme) => {
|
||||
const hostAvatarUrl =
|
||||
theme.chat.hostAvatar?.isEnabled ?? true
|
||||
? theme.chat.hostAvatar?.url
|
||||
: undefined
|
||||
const guestAvatarUrl =
|
||||
theme.chat.guestAvatar?.isEnabled ?? false
|
||||
? theme.chat.guestAvatar?.url
|
||||
: undefined
|
||||
if (!hostAvatarUrl?.startsWith('{{') && !guestAvatarUrl?.startsWith('{{'))
|
||||
return
|
||||
return {
|
||||
hostAvatarUrl: hostAvatarUrl?.startsWith('{{') ? hostAvatarUrl : undefined,
|
||||
guestAvatarUrl: guestAvatarUrl?.startsWith('{{')
|
||||
? guestAvatarUrl
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
const parseDynamicThemeReply = (
|
||||
state: SessionState | undefined
|
||||
): ChatReply['dynamicTheme'] => {
|
||||
if (!state?.dynamicTheme) return
|
||||
return {
|
||||
hostAvatarUrl: parseVariables(state.typebotsQueue[0].typebot.variables)(
|
||||
state.dynamicTheme.hostAvatarUrl
|
||||
),
|
||||
guestAvatarUrl: parseVariables(state.typebotsQueue[0].typebot.variables)(
|
||||
state.dynamicTheme.guestAvatarUrl
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
const parseStartClientSideAction = (
|
||||
typebot: StartTypebot
|
||||
): NonNullable<ChatReply['clientSideActions']>[number] | undefined => {
|
||||
const blocks = typebot.groups.flatMap((group) => group.blocks)
|
||||
const startPropsToInject = {
|
||||
customHeadCode: isNotEmpty(typebot.settings.metadata.customHeadCode)
|
||||
? parseHeadCode(typebot.settings.metadata.customHeadCode)
|
||||
: undefined,
|
||||
gtmId: typebot.settings.metadata.googleTagManagerId,
|
||||
googleAnalyticsId: (
|
||||
blocks.find(
|
||||
(block) =>
|
||||
block.type === IntegrationBlockType.GOOGLE_ANALYTICS &&
|
||||
block.options.trackingId
|
||||
) as GoogleAnalyticsBlock | undefined
|
||||
)?.options.trackingId,
|
||||
pixelId: (
|
||||
blocks.find(
|
||||
(block) =>
|
||||
block.type === IntegrationBlockType.PIXEL &&
|
||||
block.options.pixelId &&
|
||||
block.options.isInitSkip !== true
|
||||
) as PixelBlock | undefined
|
||||
)?.options.pixelId,
|
||||
}
|
||||
|
||||
if (
|
||||
!startPropsToInject.customHeadCode &&
|
||||
!startPropsToInject.gtmId &&
|
||||
!startPropsToInject.googleAnalyticsId &&
|
||||
!startPropsToInject.pixelId
|
||||
)
|
||||
return
|
||||
|
||||
return {
|
||||
startPropsToInject,
|
||||
}
|
||||
}
|
||||
|
||||
const parseHeadCode = (code: string) => {
|
||||
code = injectTryCatch(code)
|
||||
return parse(code)
|
||||
.childNodes.filter((child) => child.nodeType !== NodeType.TEXT_NODE)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
const injectTryCatch = (headCode: string) => {
|
||||
const scriptTagRegex = /<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi
|
||||
const scriptTags = headCode.match(scriptTagRegex)
|
||||
if (scriptTags) {
|
||||
scriptTags.forEach(function (tag) {
|
||||
const wrappedTag = tag.replace(
|
||||
/(<script\b[^>]*>)([\s\S]*?)(<\/script>)/gi,
|
||||
function (_, openingTag, content, closingTag) {
|
||||
if (!isValidJsSyntax(content)) return ''
|
||||
return `${openingTag}
|
||||
try {
|
||||
${content}
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
}
|
||||
${closingTag}`
|
||||
}
|
||||
)
|
||||
headCode = headCode.replace(tag, wrappedTag)
|
||||
})
|
||||
}
|
||||
return headCode
|
||||
}
|
||||
|
||||
const isValidJsSyntax = (snippet: string): boolean => {
|
||||
try {
|
||||
new Function(snippet)
|
||||
return true
|
||||
} catch (err) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import {
|
||||
AnswerInSessionState,
|
||||
Block,
|
||||
BlockType,
|
||||
BubbleBlockType,
|
||||
ChatReply,
|
||||
InputBlock,
|
||||
@@ -16,11 +14,10 @@ import {
|
||||
invalidEmailDefaultRetryMessage,
|
||||
} from '@typebot.io/schemas'
|
||||
import { isInputBlock, byId } from '@typebot.io/lib'
|
||||
import { executeGroup } from './executeGroup'
|
||||
import { executeGroup, parseInput } from './executeGroup'
|
||||
import { getNextGroup } from './getNextGroup'
|
||||
import { validateEmail } from '@/features/blocks/inputs/email/validateEmail'
|
||||
import { formatPhoneNumber } from '@/features/blocks/inputs/phone/formatPhoneNumber'
|
||||
import { validatePhoneNumber } from '@/features/blocks/inputs/phone/validatePhoneNumber'
|
||||
import { validateUrl } from '@/features/blocks/inputs/url/validateUrl'
|
||||
import { updateVariables } from '@/features/variables/updateVariables'
|
||||
import { parseVariables } from '@/features/variables/parseVariables'
|
||||
@@ -28,6 +25,13 @@ import { OpenAIBlock } from '@typebot.io/schemas/features/blocks/integrations/op
|
||||
import { resumeChatCompletion } from '@/features/blocks/integrations/openai/resumeChatCompletion'
|
||||
import { resumeWebhookExecution } from '@/features/blocks/integrations/webhook/resumeWebhookExecution'
|
||||
import { upsertAnswer } from '../queries/upsertAnswer'
|
||||
import { startBotFlow } from './startBotFlow'
|
||||
import { parseButtonsReply } from '@/features/blocks/inputs/buttons/parseButtonsReply'
|
||||
import { ParsedReply } from '../types'
|
||||
import { validateNumber } from '@/features/blocks/inputs/number/validateNumber'
|
||||
import { parseDateReply } from '@/features/blocks/inputs/date/parseDateReply'
|
||||
import { validateRatingReply } from '@/features/blocks/inputs/rating/validateRatingReply'
|
||||
import { parsePictureChoicesReply } from '@/features/blocks/inputs/pictureChoice/parsePictureChoicesReply'
|
||||
|
||||
export const continueBotFlow =
|
||||
(state: SessionState) =>
|
||||
@@ -45,11 +49,7 @@ export const continueBotFlow =
|
||||
|
||||
const block = blockIndex >= 0 ? group?.blocks[blockIndex ?? 0] : null
|
||||
|
||||
if (!block || !group)
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'Current block not found',
|
||||
})
|
||||
if (!block || !group) return startBotFlow(state)
|
||||
|
||||
if (block.type === LogicBlockType.SET_VARIABLE) {
|
||||
const existingVariable = state.typebotsQueue[0].typebot.variables.find(
|
||||
@@ -89,15 +89,15 @@ export const continueBotFlow =
|
||||
let formattedReply: string | undefined
|
||||
|
||||
if (isInputBlock(block)) {
|
||||
if (reply && !isReplyValid(reply, block))
|
||||
return { ...parseRetryMessage(block), newSessionState }
|
||||
const parseResult = parseReply(newSessionState)(reply, block)
|
||||
|
||||
formattedReply = formatReply(reply, block.type)
|
||||
|
||||
if (!formattedReply && !canSkip(block.type)) {
|
||||
return { ...parseRetryMessage(block), newSessionState }
|
||||
}
|
||||
if (parseResult.status === 'fail')
|
||||
return {
|
||||
...(await parseRetryMessage(newSessionState)(block)),
|
||||
newSessionState,
|
||||
}
|
||||
|
||||
formattedReply = 'reply' in parseResult ? parseResult.reply : undefined
|
||||
const nextEdgeId = getOutgoingEdgeId(newSessionState)(
|
||||
block,
|
||||
formattedReply
|
||||
@@ -188,26 +188,27 @@ const saveVariableValueIfAny =
|
||||
return newSessionState
|
||||
}
|
||||
|
||||
const parseRetryMessage = (
|
||||
block: InputBlock
|
||||
): Pick<ChatReply, 'messages' | 'input'> => {
|
||||
const retryMessage =
|
||||
'retryMessageContent' in block.options && block.options.retryMessageContent
|
||||
? block.options.retryMessageContent
|
||||
: parseDefaultRetryMessage(block)
|
||||
return {
|
||||
messages: [
|
||||
{
|
||||
id: block.id,
|
||||
type: BubbleBlockType.TEXT,
|
||||
content: {
|
||||
richText: [{ type: 'p', children: [{ text: retryMessage }] }],
|
||||
const parseRetryMessage =
|
||||
(state: SessionState) =>
|
||||
async (block: InputBlock): Promise<Pick<ChatReply, 'messages' | 'input'>> => {
|
||||
const retryMessage =
|
||||
'retryMessageContent' in block.options &&
|
||||
block.options.retryMessageContent
|
||||
? block.options.retryMessageContent
|
||||
: parseDefaultRetryMessage(block)
|
||||
return {
|
||||
messages: [
|
||||
{
|
||||
id: block.id,
|
||||
type: BubbleBlockType.TEXT,
|
||||
content: {
|
||||
richText: [{ type: 'p', children: [{ text: retryMessage }] }],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
input: block,
|
||||
],
|
||||
input: await parseInput(state)(block),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const parseDefaultRetryMessage = (block: InputBlock): string => {
|
||||
switch (block.type) {
|
||||
@@ -306,34 +307,61 @@ const getOutgoingEdgeId =
|
||||
return block.outgoingEdgeId
|
||||
}
|
||||
|
||||
export const formatReply = (
|
||||
inputValue: string | undefined,
|
||||
blockType: BlockType
|
||||
): string | undefined => {
|
||||
if (!inputValue) return
|
||||
switch (blockType) {
|
||||
case InputBlockType.PHONE:
|
||||
return formatPhoneNumber(inputValue)
|
||||
const parseReply =
|
||||
(state: SessionState) =>
|
||||
(inputValue: string | undefined, block: InputBlock): ParsedReply => {
|
||||
if (!inputValue) return { status: 'fail' }
|
||||
switch (block.type) {
|
||||
case InputBlockType.EMAIL: {
|
||||
const isValid = validateEmail(inputValue)
|
||||
if (!isValid) return { status: 'fail' }
|
||||
return { status: 'success', reply: inputValue }
|
||||
}
|
||||
case InputBlockType.PHONE: {
|
||||
const formattedPhone = formatPhoneNumber(
|
||||
inputValue,
|
||||
block.options.defaultCountryCode
|
||||
)
|
||||
if (!formattedPhone) return { status: 'fail' }
|
||||
return { status: 'success', reply: formattedPhone }
|
||||
}
|
||||
case InputBlockType.URL: {
|
||||
const isValid = validateUrl(inputValue)
|
||||
if (!isValid) return { status: 'fail' }
|
||||
return { status: 'success', reply: inputValue }
|
||||
}
|
||||
case InputBlockType.CHOICE: {
|
||||
return parseButtonsReply(state)(inputValue, block)
|
||||
}
|
||||
case InputBlockType.NUMBER: {
|
||||
const isValid = validateNumber(inputValue)
|
||||
if (!isValid) return { status: 'fail' }
|
||||
return { status: 'success', reply: inputValue }
|
||||
}
|
||||
case InputBlockType.DATE: {
|
||||
return parseDateReply(inputValue, block)
|
||||
}
|
||||
case InputBlockType.FILE: {
|
||||
if (!inputValue) return { status: 'skip' }
|
||||
return { status: 'success', reply: inputValue }
|
||||
}
|
||||
case InputBlockType.PAYMENT: {
|
||||
if (inputValue === 'fail') return { status: 'fail' }
|
||||
return { status: 'success', reply: inputValue }
|
||||
}
|
||||
case InputBlockType.RATING: {
|
||||
const isValid = validateRatingReply(inputValue, block)
|
||||
if (!isValid) return { status: 'fail' }
|
||||
return { status: 'success', reply: inputValue }
|
||||
}
|
||||
case InputBlockType.PICTURE_CHOICE: {
|
||||
return parsePictureChoicesReply(state)(inputValue, block)
|
||||
}
|
||||
case InputBlockType.TEXT: {
|
||||
return { status: 'success', reply: inputValue }
|
||||
}
|
||||
}
|
||||
}
|
||||
return inputValue
|
||||
}
|
||||
|
||||
export const isReplyValid = (inputValue: string, block: Block): boolean => {
|
||||
switch (block.type) {
|
||||
case InputBlockType.EMAIL:
|
||||
return validateEmail(inputValue)
|
||||
case InputBlockType.PHONE:
|
||||
return validatePhoneNumber(inputValue)
|
||||
case InputBlockType.URL:
|
||||
return validateUrl(inputValue)
|
||||
case InputBlockType.PAYMENT:
|
||||
return inputValue !== 'fail'
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export const canSkip = (inputType: InputBlockType) =>
|
||||
inputType === InputBlockType.FILE
|
||||
|
||||
export const safeJsonParse = (value: string): unknown => {
|
||||
try {
|
||||
|
||||
@@ -193,7 +193,7 @@ const parseBubbleBlock =
|
||||
}
|
||||
}
|
||||
|
||||
const parseInput =
|
||||
export const parseInput =
|
||||
(state: SessionState) =>
|
||||
async (block: InputBlock): Promise<ChatReply['input']> => {
|
||||
switch (block.type) {
|
||||
|
||||
16
apps/viewer/src/features/chat/helpers/parseDynamicTheme.ts
Normal file
16
apps/viewer/src/features/chat/helpers/parseDynamicTheme.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { parseVariables } from '@/features/variables/parseVariables'
|
||||
import { SessionState, ChatReply } from '@typebot.io/schemas'
|
||||
|
||||
export const parseDynamicTheme = (
|
||||
state: SessionState | undefined
|
||||
): ChatReply['dynamicTheme'] => {
|
||||
if (!state?.dynamicTheme) return
|
||||
return {
|
||||
hostAvatarUrl: parseVariables(state?.typebotsQueue[0].typebot.variables)(
|
||||
state.dynamicTheme.hostAvatarUrl
|
||||
),
|
||||
guestAvatarUrl: parseVariables(state?.typebotsQueue[0].typebot.variables)(
|
||||
state.dynamicTheme.guestAvatarUrl
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,10 @@ import { saveLogs } from '../queries/saveLogs'
|
||||
import { updateSession } from '../queries/updateSession'
|
||||
import { formatLogDetails } from '@/features/logs/helpers/formatLogDetails'
|
||||
import { createSession } from '../queries/createSession'
|
||||
import { deleteSession } from '../queries/deleteSession'
|
||||
|
||||
type Props = {
|
||||
isFirstSave?: boolean
|
||||
session: Pick<ChatSession, 'state'> & { id?: string }
|
||||
input: ChatReply['input']
|
||||
logs: ChatReply['logs']
|
||||
@@ -13,23 +15,30 @@ type Props = {
|
||||
}
|
||||
|
||||
export const saveStateToDatabase = async ({
|
||||
isFirstSave,
|
||||
session: { state, id },
|
||||
input,
|
||||
logs,
|
||||
clientSideActions,
|
||||
}: Props) => {
|
||||
if (id) await updateSession({ id, state })
|
||||
|
||||
const session = id ? { state, id } : await createSession({ state })
|
||||
|
||||
const resultId = state.typebotsQueue[0].resultId
|
||||
|
||||
if (!resultId) return session
|
||||
|
||||
const containsSetVariableClientSideAction = clientSideActions?.some(
|
||||
(action) => 'setVariable' in action
|
||||
)
|
||||
|
||||
const isCompleted = Boolean(!input && !containsSetVariableClientSideAction)
|
||||
|
||||
const resultId = state.typebotsQueue[0].resultId
|
||||
|
||||
if (id) {
|
||||
if (isCompleted && resultId) await deleteSession(id)
|
||||
else await updateSession({ id, state })
|
||||
}
|
||||
|
||||
const session =
|
||||
id && !isFirstSave ? { state, id } : await createSession({ id, state })
|
||||
|
||||
if (!resultId) return session
|
||||
|
||||
const answers = state.typebotsQueue[0].answers
|
||||
|
||||
await upsertResult({
|
||||
|
||||
360
apps/viewer/src/features/chat/helpers/startSession.ts
Normal file
360
apps/viewer/src/features/chat/helpers/startSession.ts
Normal file
@@ -0,0 +1,360 @@
|
||||
import { deepParseVariables } from '@/features/variables/deepParseVariable'
|
||||
import { injectVariablesFromExistingResult } from '@/features/variables/injectVariablesFromExistingResult'
|
||||
import { prefillVariables } from '@/features/variables/prefillVariables'
|
||||
import { createId } from '@paralleldrive/cuid2'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { isDefined, omit, isNotEmpty } from '@typebot.io/lib'
|
||||
import {
|
||||
Variable,
|
||||
VariableWithValue,
|
||||
Theme,
|
||||
IntegrationBlockType,
|
||||
GoogleAnalyticsBlock,
|
||||
PixelBlock,
|
||||
SessionState,
|
||||
} from '@typebot.io/schemas'
|
||||
import {
|
||||
ChatReply,
|
||||
StartParams,
|
||||
StartTypebot,
|
||||
startTypebotSchema,
|
||||
} from '@typebot.io/schemas/features/chat/schema'
|
||||
import { findPublicTypebot } from '../queries/findPublicTypebot'
|
||||
import { findResult } from '../queries/findResult'
|
||||
import { findTypebot } from '../queries/findTypebot'
|
||||
import { startBotFlow } from './startBotFlow'
|
||||
import parse, { NodeType } from 'node-html-parser'
|
||||
import { parseDynamicTheme } from './parseDynamicTheme'
|
||||
import { env } from '@typebot.io/env'
|
||||
|
||||
export const startSession = async (
|
||||
startParams?: StartParams,
|
||||
userId?: string
|
||||
): Promise<ChatReply & { newSessionState: SessionState }> => {
|
||||
if (!startParams)
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'StartParams are missing',
|
||||
})
|
||||
|
||||
const typebot = await getTypebot(startParams, userId)
|
||||
|
||||
const prefilledVariables = startParams.prefilledVariables
|
||||
? prefillVariables(typebot.variables, startParams.prefilledVariables)
|
||||
: typebot.variables
|
||||
|
||||
const result = await getResult({
|
||||
...startParams,
|
||||
isPreview: startParams.isPreview || typeof startParams.typebot !== 'string',
|
||||
typebotId: typebot.id,
|
||||
prefilledVariables,
|
||||
isRememberUserEnabled:
|
||||
typebot.settings.general.rememberUser?.isEnabled ??
|
||||
(isDefined(typebot.settings.general.isNewResultOnRefreshEnabled)
|
||||
? !typebot.settings.general.isNewResultOnRefreshEnabled
|
||||
: false),
|
||||
})
|
||||
|
||||
const startVariables =
|
||||
result && result.variables.length > 0
|
||||
? injectVariablesFromExistingResult(prefilledVariables, result.variables)
|
||||
: prefilledVariables
|
||||
|
||||
const initialState: SessionState = {
|
||||
version: '2',
|
||||
typebotsQueue: [
|
||||
{
|
||||
resultId: result?.id,
|
||||
typebot: {
|
||||
version: typebot.version,
|
||||
id: typebot.id,
|
||||
groups: typebot.groups,
|
||||
edges: typebot.edges,
|
||||
variables: startVariables,
|
||||
},
|
||||
answers: [],
|
||||
},
|
||||
],
|
||||
dynamicTheme: parseDynamicThemeInState(typebot.theme),
|
||||
isStreamEnabled: startParams.isStreamEnabled,
|
||||
typingEmulation: typebot.settings.typingEmulation,
|
||||
}
|
||||
|
||||
if (startParams.isOnlyRegistering) {
|
||||
return {
|
||||
newSessionState: initialState,
|
||||
typebot: {
|
||||
id: typebot.id,
|
||||
settings: deepParseVariables(
|
||||
initialState.typebotsQueue[0].typebot.variables
|
||||
)(typebot.settings),
|
||||
theme: deepParseVariables(
|
||||
initialState.typebotsQueue[0].typebot.variables
|
||||
)(typebot.theme),
|
||||
},
|
||||
dynamicTheme: parseDynamicTheme(initialState),
|
||||
messages: [],
|
||||
}
|
||||
}
|
||||
|
||||
const { messages, input, clientSideActions, newSessionState, logs } =
|
||||
await startBotFlow(initialState, startParams.startGroupId)
|
||||
|
||||
const clientSideActionsNeedSessionId = clientSideActions?.some(
|
||||
(action) =>
|
||||
'setVariable' in action || 'streamOpenAiChatCompletion' in action
|
||||
)
|
||||
|
||||
const startClientSideAction = clientSideActions ?? []
|
||||
|
||||
const parsedStartPropsActions = parseStartClientSideAction(typebot)
|
||||
|
||||
const startLogs = logs ?? []
|
||||
|
||||
if (isDefined(parsedStartPropsActions)) {
|
||||
if (!result) {
|
||||
if ('startPropsToInject' in parsedStartPropsActions) {
|
||||
const { customHeadCode, googleAnalyticsId, pixelId, gtmId } =
|
||||
parsedStartPropsActions.startPropsToInject
|
||||
let toolsList = ''
|
||||
if (customHeadCode) toolsList += 'Custom head code, '
|
||||
if (googleAnalyticsId) toolsList += 'Google Analytics, '
|
||||
if (pixelId) toolsList += 'Pixel, '
|
||||
if (gtmId) toolsList += 'Google Tag Manager, '
|
||||
toolsList = toolsList.slice(0, -2)
|
||||
startLogs.push({
|
||||
description: `${toolsList} ${
|
||||
toolsList.includes(',') ? 'are not' : 'is not'
|
||||
} enabled in Preview mode`,
|
||||
status: 'info',
|
||||
})
|
||||
}
|
||||
} else {
|
||||
startClientSideAction.push(parsedStartPropsActions)
|
||||
}
|
||||
}
|
||||
|
||||
if (!input && !clientSideActionsNeedSessionId)
|
||||
return {
|
||||
newSessionState,
|
||||
messages,
|
||||
clientSideActions:
|
||||
startClientSideAction.length > 0 ? startClientSideAction : undefined,
|
||||
typebot: {
|
||||
id: typebot.id,
|
||||
settings: deepParseVariables(
|
||||
newSessionState.typebotsQueue[0].typebot.variables
|
||||
)(typebot.settings),
|
||||
theme: deepParseVariables(
|
||||
newSessionState.typebotsQueue[0].typebot.variables
|
||||
)(typebot.theme),
|
||||
},
|
||||
dynamicTheme: parseDynamicTheme(newSessionState),
|
||||
logs: startLogs.length > 0 ? startLogs : undefined,
|
||||
}
|
||||
|
||||
return {
|
||||
newSessionState,
|
||||
resultId: result?.id,
|
||||
typebot: {
|
||||
id: typebot.id,
|
||||
settings: deepParseVariables(
|
||||
newSessionState.typebotsQueue[0].typebot.variables
|
||||
)(typebot.settings),
|
||||
theme: deepParseVariables(
|
||||
newSessionState.typebotsQueue[0].typebot.variables
|
||||
)(typebot.theme),
|
||||
},
|
||||
messages,
|
||||
input,
|
||||
clientSideActions:
|
||||
startClientSideAction.length > 0 ? startClientSideAction : undefined,
|
||||
dynamicTheme: parseDynamicTheme(newSessionState),
|
||||
logs: startLogs.length > 0 ? startLogs : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
const getTypebot = async (
|
||||
{ typebot, isPreview }: Pick<StartParams, 'typebot' | 'isPreview'>,
|
||||
userId?: string
|
||||
): Promise<StartTypebot> => {
|
||||
if (typeof typebot !== 'string') return typebot
|
||||
if (isPreview && !userId && !env.NEXT_PUBLIC_E2E_TEST)
|
||||
throw new TRPCError({
|
||||
code: 'UNAUTHORIZED',
|
||||
message:
|
||||
'You need to authenticate the request to start a bot in preview mode.',
|
||||
})
|
||||
const typebotQuery = isPreview
|
||||
? await findTypebot({ id: typebot, userId })
|
||||
: await findPublicTypebot({ publicId: typebot })
|
||||
|
||||
const parsedTypebot =
|
||||
typebotQuery && 'typebot' in typebotQuery
|
||||
? {
|
||||
id: typebotQuery.typebotId,
|
||||
...omit(typebotQuery.typebot, 'workspace'),
|
||||
...omit(typebotQuery, 'typebot', 'typebotId'),
|
||||
}
|
||||
: typebotQuery
|
||||
|
||||
if (!parsedTypebot || parsedTypebot.isArchived)
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Typebot not found',
|
||||
})
|
||||
|
||||
const isQuarantinedOrSuspended =
|
||||
typebotQuery &&
|
||||
'typebot' in typebotQuery &&
|
||||
(typebotQuery.typebot.workspace.isQuarantined ||
|
||||
typebotQuery.typebot.workspace.isSuspended)
|
||||
|
||||
if (
|
||||
('isClosed' in parsedTypebot && parsedTypebot.isClosed) ||
|
||||
isQuarantinedOrSuspended
|
||||
)
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Typebot is closed',
|
||||
})
|
||||
|
||||
return startTypebotSchema.parse(parsedTypebot)
|
||||
}
|
||||
|
||||
const getResult = async ({
|
||||
isPreview,
|
||||
resultId,
|
||||
prefilledVariables,
|
||||
isRememberUserEnabled,
|
||||
}: Pick<StartParams, 'isPreview' | 'resultId'> & {
|
||||
typebotId: string
|
||||
prefilledVariables: Variable[]
|
||||
isRememberUserEnabled: boolean
|
||||
}) => {
|
||||
if (isPreview) return
|
||||
const existingResult =
|
||||
resultId && isRememberUserEnabled
|
||||
? await findResult({ id: resultId })
|
||||
: undefined
|
||||
|
||||
const prefilledVariableWithValue = prefilledVariables.filter(
|
||||
(prefilledVariable) => isDefined(prefilledVariable.value)
|
||||
)
|
||||
|
||||
const updatedResult = {
|
||||
variables: prefilledVariableWithValue.concat(
|
||||
existingResult?.variables.filter(
|
||||
(resultVariable) =>
|
||||
isDefined(resultVariable.value) &&
|
||||
!prefilledVariableWithValue.some(
|
||||
(prefilledVariable) =>
|
||||
prefilledVariable.name === resultVariable.name
|
||||
)
|
||||
) ?? []
|
||||
) as VariableWithValue[],
|
||||
}
|
||||
return {
|
||||
id: existingResult?.id ?? createId(),
|
||||
variables: updatedResult.variables,
|
||||
answers: existingResult?.answers ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
const parseDynamicThemeInState = (theme: Theme) => {
|
||||
const hostAvatarUrl =
|
||||
theme.chat.hostAvatar?.isEnabled ?? true
|
||||
? theme.chat.hostAvatar?.url
|
||||
: undefined
|
||||
const guestAvatarUrl =
|
||||
theme.chat.guestAvatar?.isEnabled ?? false
|
||||
? theme.chat.guestAvatar?.url
|
||||
: undefined
|
||||
if (!hostAvatarUrl?.startsWith('{{') && !guestAvatarUrl?.startsWith('{{'))
|
||||
return
|
||||
return {
|
||||
hostAvatarUrl: hostAvatarUrl?.startsWith('{{') ? hostAvatarUrl : undefined,
|
||||
guestAvatarUrl: guestAvatarUrl?.startsWith('{{')
|
||||
? guestAvatarUrl
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
const parseStartClientSideAction = (
|
||||
typebot: StartTypebot
|
||||
): NonNullable<ChatReply['clientSideActions']>[number] | undefined => {
|
||||
const blocks = typebot.groups.flatMap((group) => group.blocks)
|
||||
const startPropsToInject = {
|
||||
customHeadCode: isNotEmpty(typebot.settings.metadata.customHeadCode)
|
||||
? parseHeadCode(typebot.settings.metadata.customHeadCode)
|
||||
: undefined,
|
||||
gtmId: typebot.settings.metadata.googleTagManagerId,
|
||||
googleAnalyticsId: (
|
||||
blocks.find(
|
||||
(block) =>
|
||||
block.type === IntegrationBlockType.GOOGLE_ANALYTICS &&
|
||||
block.options.trackingId
|
||||
) as GoogleAnalyticsBlock | undefined
|
||||
)?.options.trackingId,
|
||||
pixelId: (
|
||||
blocks.find(
|
||||
(block) =>
|
||||
block.type === IntegrationBlockType.PIXEL &&
|
||||
block.options.pixelId &&
|
||||
block.options.isInitSkip !== true
|
||||
) as PixelBlock | undefined
|
||||
)?.options.pixelId,
|
||||
}
|
||||
|
||||
if (
|
||||
!startPropsToInject.customHeadCode &&
|
||||
!startPropsToInject.gtmId &&
|
||||
!startPropsToInject.googleAnalyticsId &&
|
||||
!startPropsToInject.pixelId
|
||||
)
|
||||
return
|
||||
|
||||
return {
|
||||
startPropsToInject,
|
||||
}
|
||||
}
|
||||
|
||||
const parseHeadCode = (code: string) => {
|
||||
code = injectTryCatch(code)
|
||||
return parse(code)
|
||||
.childNodes.filter((child) => child.nodeType !== NodeType.TEXT_NODE)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
const injectTryCatch = (headCode: string) => {
|
||||
const scriptTagRegex = /<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi
|
||||
const scriptTags = headCode.match(scriptTagRegex)
|
||||
if (scriptTags) {
|
||||
scriptTags.forEach(function (tag) {
|
||||
const wrappedTag = tag.replace(
|
||||
/(<script\b[^>]*>)([\s\S]*?)(<\/script>)/gi,
|
||||
function (_, openingTag, content, closingTag) {
|
||||
if (!isValidJsSyntax(content)) return ''
|
||||
return `${openingTag}
|
||||
try {
|
||||
${content}
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
}
|
||||
${closingTag}`
|
||||
}
|
||||
)
|
||||
headCode = headCode.replace(tag, wrappedTag)
|
||||
})
|
||||
}
|
||||
return headCode
|
||||
}
|
||||
|
||||
const isValidJsSyntax = (snippet: string): boolean => {
|
||||
try {
|
||||
new Function(snippet)
|
||||
return true
|
||||
} catch (err) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,14 @@ import prisma from '@/lib/prisma'
|
||||
import { SessionState } from '@typebot.io/schemas'
|
||||
|
||||
type Props = {
|
||||
id?: string
|
||||
state: SessionState
|
||||
}
|
||||
|
||||
export const createSession = async ({ state }: Props) =>
|
||||
export const createSession = async ({ id, state }: Props) =>
|
||||
prisma.chatSession.create({
|
||||
data: {
|
||||
id,
|
||||
state,
|
||||
},
|
||||
})
|
||||
|
||||
8
apps/viewer/src/features/chat/queries/deleteSession.ts
Normal file
8
apps/viewer/src/features/chat/queries/deleteSession.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import prisma from '@/lib/prisma'
|
||||
|
||||
export const deleteSession = (id: string) =>
|
||||
prisma.chatSession.deleteMany({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
})
|
||||
24
apps/viewer/src/features/chat/queries/restartSession.ts
Normal file
24
apps/viewer/src/features/chat/queries/restartSession.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import prisma from '@/lib/prisma'
|
||||
import { SessionState } from '@typebot.io/schemas'
|
||||
|
||||
type Props = {
|
||||
id?: string
|
||||
state: SessionState
|
||||
}
|
||||
|
||||
export const restartSession = async ({ id, state }: Props) => {
|
||||
if (id) {
|
||||
await prisma.chatSession.deleteMany({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return prisma.chatSession.create({
|
||||
data: {
|
||||
id,
|
||||
state,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -11,3 +11,8 @@ export type ExecuteIntegrationResponse = {
|
||||
outgoingEdgeId: EdgeId | undefined
|
||||
newSessionState?: SessionState
|
||||
} & Pick<ChatReply, 'clientSideActions' | 'logs'>
|
||||
|
||||
export type ParsedReply =
|
||||
| { status: 'success'; reply: string }
|
||||
| { status: 'fail' }
|
||||
| { status: 'skip' }
|
||||
|
||||
42
apps/viewer/src/features/whatsApp/api/receiveMessage.ts
Normal file
42
apps/viewer/src/features/whatsApp/api/receiveMessage.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { publicProcedure } from '@/helpers/server/trpc'
|
||||
import { whatsAppWebhookRequestBodySchema } from '@typebot.io/schemas/features/whatsapp'
|
||||
import { resumeWhatsAppFlow } from '../helpers/resumeWhatsAppFlow'
|
||||
import { z } from 'zod'
|
||||
import { isNotDefined } from '@typebot.io/lib'
|
||||
|
||||
export const receiveMessage = publicProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
method: 'POST',
|
||||
path: '/workspaces/{workspaceId}/whatsapp/phoneNumbers/{phoneNumberId}/webhook',
|
||||
summary: 'Receive WhatsApp Message',
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z
|
||||
.object({ workspaceId: z.string(), phoneNumberId: z.string() })
|
||||
.merge(whatsAppWebhookRequestBodySchema)
|
||||
)
|
||||
.output(
|
||||
z.object({
|
||||
message: z.string(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input: { entry, workspaceId, phoneNumberId } }) => {
|
||||
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?.metadata.display_phone_number ?? ''
|
||||
return resumeWhatsAppFlow({
|
||||
receivedMessage,
|
||||
sessionId: `wa-${phoneNumberId}-${receivedMessage.from}`,
|
||||
phoneNumberId,
|
||||
workspaceId,
|
||||
contact: {
|
||||
name: contactName,
|
||||
phoneNumber: contactPhoneNumber,
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import { publicProcedure } from '@/helpers/server/trpc'
|
||||
import { whatsAppWebhookRequestBodySchema } from '@typebot.io/schemas/features/whatsapp'
|
||||
import { z } from 'zod'
|
||||
import { resumeWhatsAppFlow } from '../helpers/resumeWhatsAppFlow'
|
||||
import { isNotDefined } from '@typebot.io/lib'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { env } from '@typebot.io/env'
|
||||
|
||||
export const receiveMessagePreview = publicProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
method: 'POST',
|
||||
path: '/whatsapp/preview/webhook',
|
||||
summary: 'WhatsApp',
|
||||
},
|
||||
})
|
||||
.input(whatsAppWebhookRequestBodySchema)
|
||||
.output(
|
||||
z.object({
|
||||
message: z.string(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input: { entry } }) => {
|
||||
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?.metadata.display_phone_number ?? ''
|
||||
return resumeWhatsAppFlow({
|
||||
receivedMessage,
|
||||
sessionId: `wa-${receivedMessage.from}-preview`,
|
||||
phoneNumberId: env.WHATSAPP_PREVIEW_FROM_PHONE_NUMBER_ID,
|
||||
contact: {
|
||||
name: contactName,
|
||||
phoneNumber: contactPhoneNumber,
|
||||
},
|
||||
})
|
||||
})
|
||||
14
apps/viewer/src/features/whatsApp/api/router.ts
Normal file
14
apps/viewer/src/features/whatsApp/api/router.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { router } from '@/helpers/server/trpc'
|
||||
import { receiveMessagePreview } from './receiveMessagePreview'
|
||||
import { startWhatsAppPreview } from './startWhatsAppPreview'
|
||||
import { subscribePreviewWebhook } from './subscribePreviewWebhook'
|
||||
import { subscribeWebhook } from './subscribeWebhook'
|
||||
import { receiveMessage } from './receiveMessage'
|
||||
|
||||
export const whatsAppRouter = router({
|
||||
subscribePreviewWebhook,
|
||||
subscribeWebhook,
|
||||
receiveMessagePreview,
|
||||
receiveMessage,
|
||||
startWhatsAppPreview,
|
||||
})
|
||||
138
apps/viewer/src/features/whatsApp/api/startWhatsAppPreview.ts
Normal file
138
apps/viewer/src/features/whatsApp/api/startWhatsAppPreview.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { publicProcedure } from '@/helpers/server/trpc'
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { sendWhatsAppMessage } from '../helpers/sendWhatsAppMessage'
|
||||
import { startSession } from '@/features/chat/helpers/startSession'
|
||||
import { restartSession } from '@/features/chat/queries/restartSession'
|
||||
import { env } from '@typebot.io/env'
|
||||
import { HTTPError } from 'got'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { sendChatReplyToWhatsApp } from '../helpers/sendChatReplyToWhatsApp'
|
||||
import { saveStateToDatabase } from '@/features/chat/helpers/saveStateToDatabase'
|
||||
|
||||
export const startWhatsAppPreview = publicProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
method: 'POST',
|
||||
path: '/typebots/{typebotId}/whatsapp/start-preview',
|
||||
summary: 'Start WhatsApp Preview',
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
to: z
|
||||
.string()
|
||||
.min(1)
|
||||
.transform((value) => value.replace(/\s/g, '').replace(/\+/g, '')),
|
||||
typebotId: z.string(),
|
||||
startGroupId: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.output(
|
||||
z.object({
|
||||
message: z.string(),
|
||||
})
|
||||
)
|
||||
.mutation(
|
||||
async ({ input: { to, typebotId, startGroupId }, ctx: { user } }) => {
|
||||
if (
|
||||
!env.WHATSAPP_PREVIEW_FROM_PHONE_NUMBER_ID ||
|
||||
!env.META_SYSTEM_USER_TOKEN
|
||||
)
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message:
|
||||
'Missing WHATSAPP_PREVIEW_FROM_PHONE_NUMBER_ID and/or META_SYSTEM_USER_TOKEN env variables',
|
||||
})
|
||||
if (!user)
|
||||
throw new TRPCError({
|
||||
code: 'UNAUTHORIZED',
|
||||
message:
|
||||
'You need to authenticate your request in order to start a preview',
|
||||
})
|
||||
|
||||
const sessionId = `wa-${to}-preview`
|
||||
|
||||
const existingSession = await prisma.chatSession.findFirst({
|
||||
where: {
|
||||
id: sessionId,
|
||||
},
|
||||
select: {
|
||||
updatedAt: true,
|
||||
},
|
||||
})
|
||||
|
||||
// For users that did not interact with the bot in the last 24 hours, we need to send a template message.
|
||||
const canSendDirectMessagesToUser =
|
||||
(existingSession?.updatedAt.getTime() ?? 0) >
|
||||
Date.now() - 24 * 60 * 60 * 1000
|
||||
|
||||
const { newSessionState, messages, input, clientSideActions, logs } =
|
||||
await startSession({
|
||||
isOnlyRegistering: !canSendDirectMessagesToUser,
|
||||
typebot: typebotId,
|
||||
isPreview: true,
|
||||
startGroupId,
|
||||
})
|
||||
|
||||
if (canSendDirectMessagesToUser) {
|
||||
await sendChatReplyToWhatsApp({
|
||||
to,
|
||||
typingEmulation: newSessionState.typingEmulation,
|
||||
messages,
|
||||
input,
|
||||
clientSideActions,
|
||||
credentials: {
|
||||
phoneNumberId: env.WHATSAPP_PREVIEW_FROM_PHONE_NUMBER_ID,
|
||||
systemUserAccessToken: env.META_SYSTEM_USER_TOKEN,
|
||||
},
|
||||
})
|
||||
await saveStateToDatabase({
|
||||
clientSideActions: [],
|
||||
input,
|
||||
logs,
|
||||
session: {
|
||||
id: sessionId,
|
||||
state: {
|
||||
...newSessionState,
|
||||
currentBlock: !input ? undefined : newSessionState.currentBlock,
|
||||
},
|
||||
},
|
||||
})
|
||||
} else {
|
||||
await restartSession({
|
||||
state: newSessionState,
|
||||
id: `wa-${to}-preview`,
|
||||
})
|
||||
try {
|
||||
await sendWhatsAppMessage({
|
||||
to,
|
||||
message: {
|
||||
type: 'template',
|
||||
template: {
|
||||
language: {
|
||||
code: 'en',
|
||||
},
|
||||
name: 'preview_initial_message',
|
||||
},
|
||||
},
|
||||
credentials: {
|
||||
phoneNumberId: env.WHATSAPP_PREVIEW_FROM_PHONE_NUMBER_ID,
|
||||
systemUserAccessToken: env.META_SYSTEM_USER_TOKEN,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
if (err instanceof HTTPError) console.log(err.response.body)
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'Request to Meta to send preview message failed',
|
||||
cause: err,
|
||||
})
|
||||
}
|
||||
}
|
||||
return {
|
||||
message: 'success',
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,29 @@
|
||||
import { publicProcedure } from '@/helpers/server/trpc'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { env } from '@typebot.io/env'
|
||||
import { z } from 'zod'
|
||||
|
||||
export const subscribePreviewWebhook = publicProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
method: 'GET',
|
||||
path: '/whatsapp/preview/webhook',
|
||||
summary: 'WhatsApp',
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
'hub.challenge': z.string(),
|
||||
'hub.verify_token': z.string(),
|
||||
})
|
||||
)
|
||||
.output(z.number())
|
||||
.query(
|
||||
async ({
|
||||
input: { 'hub.challenge': challenge, 'hub.verify_token': token },
|
||||
}) => {
|
||||
if (token !== env.ENCRYPTION_SECRET)
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Unauthorized' })
|
||||
return Number(challenge)
|
||||
}
|
||||
)
|
||||
45
apps/viewer/src/features/whatsApp/api/subscribeWebhook.ts
Normal file
45
apps/viewer/src/features/whatsApp/api/subscribeWebhook.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { publicProcedure } from '@/helpers/server/trpc'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { z } from 'zod'
|
||||
|
||||
export const subscribeWebhook = publicProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
method: 'GET',
|
||||
path: '/workspaces/{workspaceId}/whatsapp/phoneNumbers/{phoneNumberId}/webhook',
|
||||
summary: 'Subscribe WhatsApp webhook',
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
workspaceId: z.string(),
|
||||
phoneNumberId: z.string(),
|
||||
'hub.challenge': z.string(),
|
||||
'hub.verify_token': z.string(),
|
||||
})
|
||||
)
|
||||
.output(z.number())
|
||||
.query(
|
||||
async ({
|
||||
input: { 'hub.challenge': challenge, 'hub.verify_token': token },
|
||||
}) => {
|
||||
const verificationToken = await prisma.verificationToken.findUnique({
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
})
|
||||
if (!verificationToken)
|
||||
throw new TRPCError({
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'Unauthorized',
|
||||
})
|
||||
await prisma.verificationToken.delete({
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
})
|
||||
return Number(challenge)
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,138 @@
|
||||
import { isDefined, isEmpty } from '@typebot.io/lib'
|
||||
import {
|
||||
BubbleBlockType,
|
||||
ButtonItem,
|
||||
ChatReply,
|
||||
InputBlockType,
|
||||
} from '@typebot.io/schemas'
|
||||
import { WhatsAppSendingMessage } from '@typebot.io/schemas/features/whatsapp'
|
||||
import { convertRichTextToWhatsAppText } from './convertRichTextToWhatsAppText'
|
||||
|
||||
export const convertInputToWhatsAppMessages = (
|
||||
input: NonNullable<ChatReply['input']>,
|
||||
lastMessage: ChatReply['messages'][number] | undefined
|
||||
): WhatsAppSendingMessage[] => {
|
||||
const lastMessageText =
|
||||
lastMessage?.type === BubbleBlockType.TEXT
|
||||
? convertRichTextToWhatsAppText(lastMessage.content.richText)
|
||||
: undefined
|
||||
switch (input.type) {
|
||||
case InputBlockType.DATE:
|
||||
case InputBlockType.EMAIL:
|
||||
case InputBlockType.FILE:
|
||||
case InputBlockType.NUMBER:
|
||||
case InputBlockType.PHONE:
|
||||
case InputBlockType.URL:
|
||||
case InputBlockType.PAYMENT:
|
||||
case InputBlockType.RATING:
|
||||
case InputBlockType.TEXT:
|
||||
return []
|
||||
case InputBlockType.PICTURE_CHOICE: {
|
||||
if (input.options.isMultipleChoice)
|
||||
return input.items.flatMap((item, idx) => {
|
||||
let bodyText = ''
|
||||
if (item.title) bodyText += `*${item.title}*`
|
||||
if (item.description) {
|
||||
if (item.title) bodyText += '\n\n'
|
||||
bodyText += item.description
|
||||
}
|
||||
const imageMessage = item.pictureSrc
|
||||
? ({
|
||||
type: 'image',
|
||||
image: {
|
||||
link: item.pictureSrc ?? '',
|
||||
},
|
||||
} as const)
|
||||
: undefined
|
||||
const textMessage = {
|
||||
type: 'text',
|
||||
text: {
|
||||
body: `${idx + 1}. ${bodyText}`,
|
||||
},
|
||||
} as const
|
||||
return imageMessage ? [imageMessage, textMessage] : textMessage
|
||||
})
|
||||
return input.items.map((item) => {
|
||||
let bodyText = ''
|
||||
if (item.title) bodyText += `*${item.title}*`
|
||||
if (item.description) {
|
||||
if (item.title) bodyText += '\n\n'
|
||||
bodyText += item.description
|
||||
}
|
||||
return {
|
||||
type: 'interactive',
|
||||
interactive: {
|
||||
type: 'button',
|
||||
header: item.pictureSrc
|
||||
? {
|
||||
type: 'image',
|
||||
image: {
|
||||
link: item.pictureSrc,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
body: isEmpty(bodyText) ? undefined : { text: bodyText },
|
||||
action: {
|
||||
buttons: [
|
||||
{
|
||||
type: 'reply',
|
||||
reply: {
|
||||
id: item.id,
|
||||
title: 'Select',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
case InputBlockType.CHOICE: {
|
||||
if (input.options.isMultipleChoice)
|
||||
return [
|
||||
{
|
||||
type: 'text',
|
||||
text: {
|
||||
body:
|
||||
`${lastMessageText}\n\n` +
|
||||
input.items
|
||||
.map((item, idx) => `${idx + 1}. ${item.content}`)
|
||||
.join('\n'),
|
||||
},
|
||||
},
|
||||
]
|
||||
const items = groupArrayByArraySize(
|
||||
input.items.filter((item) => isDefined(item.content)),
|
||||
3
|
||||
) as ButtonItem[][]
|
||||
return items.map((items, idx) => ({
|
||||
type: 'interactive',
|
||||
interactive: {
|
||||
type: 'button',
|
||||
body: {
|
||||
text: idx === 0 ? lastMessageText ?? '...' : '...',
|
||||
},
|
||||
action: {
|
||||
buttons: items.map((item) => ({
|
||||
type: 'reply',
|
||||
reply: {
|
||||
id: item.id,
|
||||
title: trimTextTo20Chars(item.content as string),
|
||||
},
|
||||
})),
|
||||
},
|
||||
},
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const trimTextTo20Chars = (text: string): string =>
|
||||
text.length > 20 ? `${text.slice(0, 18)}..` : text
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const groupArrayByArraySize = (arr: any[], n: number) =>
|
||||
arr.reduce(
|
||||
(r, e, i) => (i % n ? r[r.length - 1].push(e) : r.push([e])) && r,
|
||||
[]
|
||||
)
|
||||
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
BubbleBlockType,
|
||||
ChatReply,
|
||||
VideoBubbleContentType,
|
||||
} from '@typebot.io/schemas'
|
||||
import { WhatsAppSendingMessage } from '@typebot.io/schemas/features/whatsapp'
|
||||
import { convertRichTextToWhatsAppText } from './convertRichTextToWhatsAppText'
|
||||
import { isSvgSrc } from '@typebot.io/lib'
|
||||
|
||||
const mp4HttpsUrlRegex = /^https:\/\/.*\.mp4$/
|
||||
|
||||
export const convertMessageToWhatsAppMessage = (
|
||||
message: ChatReply['messages'][number]
|
||||
): WhatsAppSendingMessage | undefined => {
|
||||
switch (message.type) {
|
||||
case BubbleBlockType.TEXT: {
|
||||
if (!message.content.richText || message.content.richText.length === 0)
|
||||
return
|
||||
return {
|
||||
type: 'text',
|
||||
text: {
|
||||
body: convertRichTextToWhatsAppText(message.content.richText),
|
||||
},
|
||||
}
|
||||
}
|
||||
case BubbleBlockType.IMAGE: {
|
||||
if (!message.content.url || isImageUrlNotCompatible(message.content.url))
|
||||
return
|
||||
return {
|
||||
type: 'image',
|
||||
image: {
|
||||
link: message.content.url,
|
||||
},
|
||||
}
|
||||
}
|
||||
case BubbleBlockType.AUDIO: {
|
||||
if (!message.content.url) return
|
||||
return {
|
||||
type: 'audio',
|
||||
audio: {
|
||||
link: message.content.url,
|
||||
},
|
||||
}
|
||||
}
|
||||
case BubbleBlockType.VIDEO: {
|
||||
if (
|
||||
!message.content.url ||
|
||||
(message.content.type !== VideoBubbleContentType.URL &&
|
||||
isVideoUrlNotCompatible(message.content.url))
|
||||
)
|
||||
return
|
||||
return {
|
||||
type: 'video',
|
||||
video: {
|
||||
link: message.content.url,
|
||||
},
|
||||
}
|
||||
}
|
||||
case BubbleBlockType.EMBED: {
|
||||
if (!message.content.url) return
|
||||
return {
|
||||
type: 'text',
|
||||
text: {
|
||||
body: message.content.url,
|
||||
},
|
||||
preview_url: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const isImageUrlNotCompatible = (url: string) =>
|
||||
!isHttpUrl(url) || isGifFileUrl(url) || isSvgSrc(url)
|
||||
|
||||
export const isVideoUrlNotCompatible = (url: string) =>
|
||||
!mp4HttpsUrlRegex.test(url)
|
||||
|
||||
export const isHttpUrl = (text: string) =>
|
||||
text.startsWith('http://') || text.startsWith('https://')
|
||||
|
||||
export const isGifFileUrl = (url: string) => {
|
||||
const urlWithoutQueryParams = url.split('?')[0]
|
||||
return urlWithoutQueryParams.endsWith('.gif')
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { TElement } from '@udecode/plate-common'
|
||||
import { serialize } from 'remark-slate'
|
||||
|
||||
export const convertRichTextToWhatsAppText = (richText: TElement[]): string =>
|
||||
richText
|
||||
.map((chunk) =>
|
||||
serialize(chunk)?.replaceAll('**', '*').replaceAll('&#39;', "'")
|
||||
)
|
||||
.join('')
|
||||
46
apps/viewer/src/features/whatsApp/helpers/downloadMedia.ts
Normal file
46
apps/viewer/src/features/whatsApp/helpers/downloadMedia.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import got from 'got'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { uploadFileToBucket } from '@typebot.io/lib/api/uploadFileToBucket'
|
||||
|
||||
type Props = {
|
||||
mediaId: string
|
||||
systemUserToken: string
|
||||
downloadPath: string
|
||||
}
|
||||
|
||||
export const downloadMedia = async ({
|
||||
mediaId,
|
||||
systemUserToken,
|
||||
downloadPath,
|
||||
}: Props) => {
|
||||
const { body } = await got.get({
|
||||
url: `https://graph.facebook.com/v17.0/${mediaId}`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${systemUserToken}`,
|
||||
},
|
||||
})
|
||||
const parsedBody = JSON.parse(body) as { url: string; mime_type: string }
|
||||
if (!parsedBody.url)
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'Request to Facebook failed. Could not find media url.',
|
||||
cause: body,
|
||||
})
|
||||
const streamBuffer = await got(parsedBody.url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${systemUserToken}`,
|
||||
},
|
||||
}).buffer()
|
||||
const typebotUrl = await uploadFileToBucket({
|
||||
fileName: `public/${downloadPath}/${mediaId}`,
|
||||
file: streamBuffer,
|
||||
mimeType: parsedBody.mime_type,
|
||||
})
|
||||
await got.delete({
|
||||
url: `https://graph.facebook.com/v17.0/${mediaId}`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${systemUserToken}`,
|
||||
},
|
||||
})
|
||||
return typebotUrl
|
||||
}
|
||||
192
apps/viewer/src/features/whatsApp/helpers/resumeWhatsAppFlow.ts
Normal file
192
apps/viewer/src/features/whatsApp/helpers/resumeWhatsAppFlow.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { continueBotFlow } from '@/features/chat/helpers/continueBotFlow'
|
||||
import { saveStateToDatabase } from '@/features/chat/helpers/saveStateToDatabase'
|
||||
import { getSession } from '@/features/chat/queries/getSession'
|
||||
import { SessionState } from '@typebot.io/schemas'
|
||||
import {
|
||||
WhatsAppCredentials,
|
||||
WhatsAppIncomingMessage,
|
||||
} from '@typebot.io/schemas/features/whatsapp'
|
||||
import { startWhatsAppSession } from './startWhatsAppSession'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { decrypt } from '@typebot.io/lib/api'
|
||||
import { downloadMedia } from './downloadMedia'
|
||||
import { env } from '@typebot.io/env'
|
||||
import { sendChatReplyToWhatsApp } from './sendChatReplyToWhatsApp'
|
||||
|
||||
export const resumeWhatsAppFlow = async ({
|
||||
receivedMessage,
|
||||
sessionId,
|
||||
workspaceId,
|
||||
phoneNumberId,
|
||||
contact,
|
||||
}: {
|
||||
receivedMessage: WhatsAppIncomingMessage
|
||||
sessionId: string
|
||||
phoneNumberId: string
|
||||
workspaceId?: string
|
||||
contact: NonNullable<SessionState['whatsApp']>['contact']
|
||||
}) => {
|
||||
const messageSendDate = new Date(Number(receivedMessage.timestamp) * 1000)
|
||||
const messageSentBefore3MinutesAgo =
|
||||
messageSendDate.getTime() < Date.now() - 180000
|
||||
if (messageSentBefore3MinutesAgo) {
|
||||
console.log('Message is too old', messageSendDate.getTime())
|
||||
return {
|
||||
message: 'Message received',
|
||||
}
|
||||
}
|
||||
|
||||
const session = await getSession(sessionId)
|
||||
|
||||
const initialCredentials = session
|
||||
? await getCredentials(phoneNumberId)(session.state)
|
||||
: undefined
|
||||
|
||||
const { typebot, resultId } = session?.state.typebotsQueue[0] ?? {}
|
||||
const messageContent = await getIncomingMessageContent({
|
||||
message: receivedMessage,
|
||||
systemUserToken: initialCredentials?.systemUserAccessToken,
|
||||
downloadPath:
|
||||
typebot && resultId
|
||||
? `typebots/${typebot.id}/results/${resultId}`
|
||||
: undefined,
|
||||
})
|
||||
|
||||
const isPreview = workspaceId === undefined
|
||||
|
||||
const sessionState =
|
||||
isPreview && session?.state
|
||||
? ({
|
||||
...session?.state,
|
||||
whatsApp: {
|
||||
contact,
|
||||
},
|
||||
} satisfies SessionState)
|
||||
: session?.state
|
||||
|
||||
const resumeResponse = sessionState
|
||||
? await continueBotFlow(sessionState)(messageContent)
|
||||
: workspaceId
|
||||
? await startWhatsAppSession({
|
||||
message: receivedMessage,
|
||||
sessionId,
|
||||
workspaceId,
|
||||
phoneNumberId,
|
||||
contact,
|
||||
})
|
||||
: undefined
|
||||
|
||||
if (!resumeResponse) {
|
||||
console.error('Could not find or create session', sessionId)
|
||||
return {
|
||||
message: 'Message received',
|
||||
}
|
||||
}
|
||||
|
||||
const credentials =
|
||||
initialCredentials ??
|
||||
(await getCredentials(phoneNumberId)(resumeResponse.newSessionState))
|
||||
|
||||
if (!credentials) {
|
||||
console.error('Could not find credentials')
|
||||
return {
|
||||
message: 'Message received',
|
||||
}
|
||||
}
|
||||
|
||||
const { input, logs, newSessionState, messages, clientSideActions } =
|
||||
resumeResponse
|
||||
|
||||
await sendChatReplyToWhatsApp({
|
||||
to: receivedMessage.from,
|
||||
messages,
|
||||
input,
|
||||
typingEmulation: newSessionState.typingEmulation,
|
||||
clientSideActions,
|
||||
credentials,
|
||||
})
|
||||
|
||||
await saveStateToDatabase({
|
||||
isFirstSave: !session,
|
||||
clientSideActions: [],
|
||||
input,
|
||||
logs,
|
||||
session: {
|
||||
id: sessionId,
|
||||
state: {
|
||||
...newSessionState,
|
||||
currentBlock: !input ? undefined : newSessionState.currentBlock,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
message: 'Message received',
|
||||
}
|
||||
}
|
||||
|
||||
const getIncomingMessageContent = async ({
|
||||
message,
|
||||
systemUserToken,
|
||||
downloadPath,
|
||||
}: {
|
||||
message: WhatsAppIncomingMessage
|
||||
systemUserToken: string | undefined
|
||||
downloadPath?: string
|
||||
}): Promise<string> => {
|
||||
switch (message.type) {
|
||||
case 'text':
|
||||
return message.text.body
|
||||
case 'button':
|
||||
return message.button.text
|
||||
case 'interactive': {
|
||||
return message.interactive.button_reply.id
|
||||
}
|
||||
case 'document':
|
||||
case 'audio':
|
||||
return ''
|
||||
case 'video':
|
||||
case 'image':
|
||||
if (!systemUserToken || !downloadPath) return ''
|
||||
return downloadMedia({
|
||||
mediaId: 'video' in message ? message.video.id : message.image.id,
|
||||
systemUserToken,
|
||||
downloadPath,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const getCredentials =
|
||||
(phoneNumberId: string) =>
|
||||
async (
|
||||
state: SessionState
|
||||
): Promise<WhatsAppCredentials['data'] | undefined> => {
|
||||
const isPreview = !state.typebotsQueue[0].resultId
|
||||
if (isPreview) {
|
||||
if (!env.META_SYSTEM_USER_TOKEN) return
|
||||
return {
|
||||
systemUserAccessToken: env.META_SYSTEM_USER_TOKEN,
|
||||
phoneNumberId,
|
||||
}
|
||||
}
|
||||
if (!state.whatsApp) return
|
||||
|
||||
const credentials = await prisma.credentials.findUnique({
|
||||
where: {
|
||||
id: state.whatsApp.credentialsId,
|
||||
},
|
||||
select: {
|
||||
data: true,
|
||||
iv: true,
|
||||
},
|
||||
})
|
||||
if (!credentials) return
|
||||
const data = (await decrypt(
|
||||
credentials.data,
|
||||
credentials.iv
|
||||
)) as WhatsAppCredentials['data']
|
||||
return {
|
||||
systemUserAccessToken: data.systemUserAccessToken,
|
||||
phoneNumberId,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import {
|
||||
ChatReply,
|
||||
InputBlockType,
|
||||
SessionState,
|
||||
Settings,
|
||||
} from '@typebot.io/schemas'
|
||||
import {
|
||||
WhatsAppCredentials,
|
||||
WhatsAppSendingMessage,
|
||||
} from '@typebot.io/schemas/features/whatsapp'
|
||||
import { convertMessageToWhatsAppMessage } from './convertMessageToWhatsAppMessage'
|
||||
import { sendWhatsAppMessage } from './sendWhatsAppMessage'
|
||||
import { captureException } from '@sentry/nextjs'
|
||||
import { isNotDefined } from '@typebot.io/lib/utils'
|
||||
import { HTTPError } from 'got'
|
||||
import { computeTypingDuration } from '@typebot.io/lib/computeTypingDuration'
|
||||
import { convertInputToWhatsAppMessages } from './convertInputToWhatsAppMessage'
|
||||
|
||||
// Media can take some time to be delivered. This make sure we don't send a message before the media is delivered.
|
||||
const messageAfterMediaTimeout = 5000
|
||||
|
||||
type Props = {
|
||||
to: string
|
||||
typingEmulation: SessionState['typingEmulation']
|
||||
credentials: WhatsAppCredentials['data']
|
||||
} & Pick<ChatReply, 'messages' | 'input' | 'clientSideActions'>
|
||||
|
||||
export const sendChatReplyToWhatsApp = async ({
|
||||
to,
|
||||
typingEmulation,
|
||||
messages,
|
||||
input,
|
||||
clientSideActions,
|
||||
credentials,
|
||||
}: Props) => {
|
||||
const messagesBeforeInput = isLastMessageIncludedInInput(input)
|
||||
? messages.slice(0, -1)
|
||||
: messages
|
||||
|
||||
const sentMessages: WhatsAppSendingMessage[] = []
|
||||
|
||||
for (const message of messagesBeforeInput) {
|
||||
const whatsAppMessage = convertMessageToWhatsAppMessage(message)
|
||||
if (isNotDefined(whatsAppMessage)) continue
|
||||
const lastSentMessageIsMedia = ['audio', 'video', 'image'].includes(
|
||||
sentMessages.at(-1)?.type ?? ''
|
||||
)
|
||||
const typingDuration = lastSentMessageIsMedia
|
||||
? messageAfterMediaTimeout
|
||||
: getTypingDuration({
|
||||
message: whatsAppMessage,
|
||||
typingEmulation,
|
||||
})
|
||||
if (typingDuration)
|
||||
await new Promise((resolve) => setTimeout(resolve, typingDuration))
|
||||
try {
|
||||
await sendWhatsAppMessage({
|
||||
to,
|
||||
message: whatsAppMessage,
|
||||
credentials,
|
||||
})
|
||||
sentMessages.push(whatsAppMessage)
|
||||
} catch (err) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
if (clientSideActions)
|
||||
for (const clientSideAction of clientSideActions) {
|
||||
if ('redirect' in clientSideAction && clientSideAction.redirect.url) {
|
||||
const message = {
|
||||
type: 'text',
|
||||
text: {
|
||||
body: clientSideAction.redirect.url,
|
||||
preview_url: true,
|
||||
},
|
||||
} satisfies WhatsAppSendingMessage
|
||||
try {
|
||||
await sendWhatsAppMessage({
|
||||
to,
|
||||
message,
|
||||
credentials,
|
||||
})
|
||||
} catch (err) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (input) {
|
||||
const inputWhatsAppMessages = convertInputToWhatsAppMessages(
|
||||
input,
|
||||
messages.at(-1)
|
||||
)
|
||||
for (const message of inputWhatsAppMessages) {
|
||||
try {
|
||||
const lastSentMessageIsMedia = ['audio', 'video', 'image'].includes(
|
||||
sentMessages.at(-1)?.type ?? ''
|
||||
)
|
||||
const typingDuration = lastSentMessageIsMedia
|
||||
? messageAfterMediaTimeout
|
||||
: getTypingDuration({
|
||||
message,
|
||||
typingEmulation,
|
||||
})
|
||||
if (typingDuration)
|
||||
await new Promise((resolve) => setTimeout(resolve, typingDuration))
|
||||
await sendWhatsAppMessage({
|
||||
to,
|
||||
message,
|
||||
credentials,
|
||||
})
|
||||
} catch (err) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getTypingDuration = ({
|
||||
message,
|
||||
typingEmulation,
|
||||
}: {
|
||||
message: WhatsAppSendingMessage
|
||||
typingEmulation?: Settings['typingEmulation']
|
||||
}): number | undefined => {
|
||||
switch (message.type) {
|
||||
case 'text':
|
||||
return computeTypingDuration({
|
||||
bubbleContent: message.text.body,
|
||||
typingSettings: typingEmulation,
|
||||
})
|
||||
case 'interactive':
|
||||
if (!message.interactive.body?.text) return
|
||||
return computeTypingDuration({
|
||||
bubbleContent: message.interactive.body?.text ?? '',
|
||||
typingSettings: typingEmulation,
|
||||
})
|
||||
case 'audio':
|
||||
case 'video':
|
||||
case 'image':
|
||||
case 'template':
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const isLastMessageIncludedInInput = (input: ChatReply['input']): boolean => {
|
||||
if (isNotDefined(input)) return false
|
||||
return input.type === InputBlockType.CHOICE
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import got from 'got'
|
||||
import {
|
||||
WhatsAppCredentials,
|
||||
WhatsAppSendingMessage,
|
||||
} from '@typebot.io/schemas/features/whatsapp'
|
||||
|
||||
type Props = {
|
||||
to: string
|
||||
message: WhatsAppSendingMessage
|
||||
credentials: WhatsAppCredentials['data']
|
||||
}
|
||||
|
||||
export const sendWhatsAppMessage = async ({
|
||||
to,
|
||||
message,
|
||||
credentials,
|
||||
}: Props) =>
|
||||
got.post({
|
||||
url: `https://graph.facebook.com/v17.0/${credentials.phoneNumberId}/messages`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${credentials.systemUserAccessToken}`,
|
||||
},
|
||||
json: {
|
||||
messaging_product: 'whatsapp',
|
||||
to,
|
||||
...message,
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,193 @@
|
||||
import { startSession } from '@/features/chat/helpers/startSession'
|
||||
import prisma from '@/lib/prisma'
|
||||
import {
|
||||
ChatReply,
|
||||
ComparisonOperators,
|
||||
LogicalOperator,
|
||||
PublicTypebot,
|
||||
SessionState,
|
||||
Settings,
|
||||
Typebot,
|
||||
} from '@typebot.io/schemas'
|
||||
import {
|
||||
WhatsAppCredentials,
|
||||
WhatsAppIncomingMessage,
|
||||
} from '@typebot.io/schemas/features/whatsapp'
|
||||
import { isNotDefined } from '@typebot.io/lib'
|
||||
import { decrypt } from '@typebot.io/lib/api/encryption'
|
||||
|
||||
type Props = {
|
||||
message: WhatsAppIncomingMessage
|
||||
sessionId: string
|
||||
workspaceId?: string
|
||||
phoneNumberId: string
|
||||
contact: NonNullable<SessionState['whatsApp']>['contact']
|
||||
}
|
||||
|
||||
export const startWhatsAppSession = async ({
|
||||
message,
|
||||
workspaceId,
|
||||
phoneNumberId,
|
||||
contact,
|
||||
}: Props): Promise<
|
||||
| (ChatReply & {
|
||||
newSessionState: SessionState
|
||||
})
|
||||
| undefined
|
||||
> => {
|
||||
const publicTypebotsWithWhatsAppEnabled =
|
||||
(await prisma.publicTypebot.findMany({
|
||||
where: {
|
||||
typebot: { workspaceId, whatsAppPhoneNumberId: phoneNumberId },
|
||||
},
|
||||
select: {
|
||||
settings: true,
|
||||
typebot: {
|
||||
select: {
|
||||
publicId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})) as (Pick<PublicTypebot, 'settings'> & {
|
||||
typebot: Pick<Typebot, 'publicId'>
|
||||
})[]
|
||||
|
||||
const botsWithWhatsAppEnabled = publicTypebotsWithWhatsAppEnabled.filter(
|
||||
(publicTypebot) =>
|
||||
publicTypebot.typebot.publicId &&
|
||||
publicTypebot.settings.whatsApp?.credentialsId
|
||||
)
|
||||
|
||||
const publicTypebot =
|
||||
botsWithWhatsAppEnabled.find(
|
||||
(publicTypebot) =>
|
||||
publicTypebot.settings.whatsApp?.startCondition &&
|
||||
messageMatchStartCondition(
|
||||
getIncomingMessageText(message),
|
||||
publicTypebot.settings.whatsApp?.startCondition
|
||||
)
|
||||
) ?? botsWithWhatsAppEnabled[0]
|
||||
|
||||
if (isNotDefined(publicTypebot)) return
|
||||
|
||||
const encryptedCredentials = await prisma.credentials.findUnique({
|
||||
where: {
|
||||
id: publicTypebot.settings.whatsApp?.credentialsId,
|
||||
},
|
||||
})
|
||||
if (!encryptedCredentials) return
|
||||
const credentials = (await decrypt(
|
||||
encryptedCredentials?.data,
|
||||
encryptedCredentials?.iv
|
||||
)) as WhatsAppCredentials['data']
|
||||
|
||||
if (credentials.phoneNumberId !== phoneNumberId) return
|
||||
|
||||
const session = await startSession({
|
||||
typebot: publicTypebot.typebot.publicId as string,
|
||||
})
|
||||
|
||||
return {
|
||||
...session,
|
||||
newSessionState: {
|
||||
...session.newSessionState,
|
||||
whatsApp: {
|
||||
contact,
|
||||
credentialsId: publicTypebot?.settings.whatsApp
|
||||
?.credentialsId as string,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const messageMatchStartCondition = (
|
||||
message: string,
|
||||
startCondition: NonNullable<Settings['whatsApp']>['startCondition']
|
||||
) => {
|
||||
if (!startCondition) return true
|
||||
return startCondition.logicalOperator === LogicalOperator.AND
|
||||
? startCondition.comparisons.every((comparison) =>
|
||||
matchComparison(
|
||||
message,
|
||||
comparison.comparisonOperator,
|
||||
comparison.value
|
||||
)
|
||||
)
|
||||
: startCondition.comparisons.some((comparison) =>
|
||||
matchComparison(
|
||||
message,
|
||||
comparison.comparisonOperator,
|
||||
comparison.value
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const matchComparison = (
|
||||
inputValue: string,
|
||||
comparisonOperator?: ComparisonOperators,
|
||||
value?: string
|
||||
): boolean | undefined => {
|
||||
if (!comparisonOperator) return false
|
||||
switch (comparisonOperator) {
|
||||
case ComparisonOperators.CONTAINS: {
|
||||
if (!value) return false
|
||||
return inputValue
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.includes(value.trim().toLowerCase())
|
||||
}
|
||||
case ComparisonOperators.EQUAL: {
|
||||
return inputValue === value
|
||||
}
|
||||
case ComparisonOperators.NOT_EQUAL: {
|
||||
return inputValue !== value
|
||||
}
|
||||
case ComparisonOperators.GREATER: {
|
||||
if (!value) return false
|
||||
return parseFloat(inputValue) > parseFloat(value)
|
||||
}
|
||||
case ComparisonOperators.LESS: {
|
||||
if (!value) return false
|
||||
return parseFloat(inputValue) < parseFloat(value)
|
||||
}
|
||||
case ComparisonOperators.IS_SET: {
|
||||
return inputValue.length > 0
|
||||
}
|
||||
case ComparisonOperators.IS_EMPTY: {
|
||||
return inputValue.length === 0
|
||||
}
|
||||
case ComparisonOperators.STARTS_WITH: {
|
||||
if (!value) return false
|
||||
return inputValue.toLowerCase().startsWith(value.toLowerCase())
|
||||
}
|
||||
case ComparisonOperators.ENDS_WITH: {
|
||||
if (!value) return false
|
||||
return inputValue.toLowerCase().endsWith(value.toLowerCase())
|
||||
}
|
||||
case ComparisonOperators.NOT_CONTAINS: {
|
||||
if (!value) return false
|
||||
return !inputValue
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.includes(value.trim().toLowerCase())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getIncomingMessageText = (message: WhatsAppIncomingMessage): string => {
|
||||
switch (message.type) {
|
||||
case 'text':
|
||||
return message.text.body
|
||||
case 'button':
|
||||
return message.button.text
|
||||
case 'interactive': {
|
||||
return message.interactive.button_reply.title
|
||||
}
|
||||
case 'video':
|
||||
case 'document':
|
||||
case 'audio':
|
||||
case 'image': {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getUploadUrl } from '@/features/blocks/inputs/fileUpload/api/getUploadUrl'
|
||||
import { sendMessage } from '@/features/chat/api/sendMessage'
|
||||
import { whatsAppRouter } from '@/features/whatsApp/api/router'
|
||||
import { router } from '../../trpc'
|
||||
import { updateTypebotInSession } from '@/features/chat/api/updateTypebotInSession'
|
||||
|
||||
@@ -7,6 +8,7 @@ export const appRouter = router({
|
||||
sendMessage,
|
||||
getUploadUrl,
|
||||
updateTypebotInSession,
|
||||
whatsAppRouter,
|
||||
})
|
||||
|
||||
export type AppRouter = typeof appRouter
|
||||
|
||||
Reference in New Issue
Block a user