2
0

Add WhatsApp integration beta test (#722)

Related to #401
This commit is contained in:
Baptiste Arnaud
2023-08-29 10:01:28 +02:00
parent 036b407a11
commit b852b4af0b
136 changed files with 6694 additions and 5383 deletions

View File

@@ -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 ?? '',
}
}

View File

@@ -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,
}
}

View File

@@ -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}` : ''}`
}

View File

@@ -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

View File

@@ -0,0 +1 @@
export const validateNumber = (inputValue: string) => !isNaN(Number(inputValue))

View File

@@ -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

View File

@@ -1,4 +0,0 @@
import { isValidPhoneNumber } from 'libphonenumber-js'
export const validatePhoneNumber = (phoneNumber: string) =>
isValidPhoneNumber(phoneNumber)

View File

@@ -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 ?? '',
}
}

View File

@@ -0,0 +1,4 @@
import { RatingInputBlock } from '@typebot.io/schemas'
export const validateRatingReply = (reply: string, block: RatingInputBlock) =>
Number(reply) <= block.options.length

View File

@@ -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'

View File

@@ -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,

View File

@@ -72,7 +72,8 @@ export const createChatCompletionOpenAI = async (
if (
isPlaneteScale() &&
isCredentialsV2(credentials) &&
newSessionState.isStreamEnabled
newSessionState.isStreamEnabled &&
!newSessionState.whatsApp
) {
const assistantMessageVariableName = typebot.variables.find(
(variable) =>

View File

@@ -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,

View File

@@ -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: [

View File

@@ -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,

View File

@@ -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})

View File

@@ -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
}
}

View File

@@ -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 {

View File

@@ -193,7 +193,7 @@ const parseBubbleBlock =
}
}
const parseInput =
export const parseInput =
(state: SessionState) =>
async (block: InputBlock): Promise<ChatReply['input']> => {
switch (block.type) {

View 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
),
}
}

View File

@@ -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({

View 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
}
}

View File

@@ -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,
},
})

View File

@@ -0,0 +1,8 @@
import prisma from '@/lib/prisma'
export const deleteSession = (id: string) =>
prisma.chatSession.deleteMany({
where: {
id,
},
})

View 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,
},
})
}

View File

@@ -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' }

View 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,
},
})
})

View File

@@ -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,
},
})
})

View 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,
})

View 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',
}
}
)

View File

@@ -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)
}
)

View 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)
}
)

View File

@@ -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,
[]
)

View File

@@ -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')
}

View File

@@ -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('&amp;#39;', "'")
)
.join('')

View 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
}

View 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,
}
}

View File

@@ -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
}

View File

@@ -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,
},
})

View File

@@ -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 ''
}
}
}

View File

@@ -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