@ -7,10 +7,11 @@ import { parseDynamicTheme } from '../parseDynamicTheme'
|
||||
import { saveStateToDatabase } from '../saveStateToDatabase'
|
||||
import { computeCurrentProgress } from '../computeCurrentProgress'
|
||||
import { BubbleBlockType } from '@typebot.io/schemas/features/blocks/bubbles/constants'
|
||||
import { Message } from '@typebot.io/schemas'
|
||||
|
||||
type Props = {
|
||||
origin: string | undefined
|
||||
message?: string
|
||||
message?: Message
|
||||
sessionId: string
|
||||
textBubbleContentFormat: 'richText' | 'markdown'
|
||||
}
|
||||
|
@ -1,14 +1,14 @@
|
||||
import { BubbleBlockType } from '@typebot.io/schemas/features/blocks/bubbles/constants'
|
||||
import { Message } from '@typebot.io/schemas'
|
||||
import { computeCurrentProgress } from '../computeCurrentProgress'
|
||||
import { filterPotentiallySensitiveLogs } from '../logs/filterPotentiallySensitiveLogs'
|
||||
import { restartSession } from '../queries/restartSession'
|
||||
import { saveStateToDatabase } from '../saveStateToDatabase'
|
||||
import { startSession } from '../startSession'
|
||||
import { isNotEmpty } from '@typebot.io/lib'
|
||||
|
||||
type Props = {
|
||||
origin: string | undefined
|
||||
message?: string
|
||||
message?: Message
|
||||
isOnlyRegistering: boolean
|
||||
publicId: string
|
||||
isStreamEnabled: boolean
|
||||
@ -48,8 +48,8 @@ export const startChat = async ({
|
||||
prefilledVariables,
|
||||
resultId: startResultId,
|
||||
textBubbleContentFormat,
|
||||
message,
|
||||
},
|
||||
message,
|
||||
})
|
||||
|
||||
let corsOrigin
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { StartFrom, StartTypebot } from '@typebot.io/schemas'
|
||||
import { Message, StartFrom, StartTypebot } from '@typebot.io/schemas'
|
||||
import { restartSession } from '../queries/restartSession'
|
||||
import { saveStateToDatabase } from '../saveStateToDatabase'
|
||||
import { startSession } from '../startSession'
|
||||
@ -6,7 +6,7 @@ import { computeCurrentProgress } from '../computeCurrentProgress'
|
||||
import { BubbleBlockType } from '@typebot.io/schemas/features/blocks/bubbles/constants'
|
||||
|
||||
type Props = {
|
||||
message?: string
|
||||
message?: Message
|
||||
isOnlyRegistering: boolean
|
||||
isStreamEnabled: boolean
|
||||
startFrom?: StartFrom
|
||||
@ -53,8 +53,8 @@ export const startChatPreview = async ({
|
||||
prefilledVariables,
|
||||
sessionId,
|
||||
textBubbleContentFormat,
|
||||
message,
|
||||
},
|
||||
message,
|
||||
})
|
||||
|
||||
const session = isOnlyRegistering
|
||||
|
@ -23,6 +23,7 @@ import {
|
||||
} from '@typebot.io/schemas/features/blocks/logic/setVariable/constants'
|
||||
import { createCodeRunner } from '@typebot.io/variables/codeRunners'
|
||||
import { stringifyError } from '@typebot.io/lib/stringifyError'
|
||||
import { AnswerV2 } from '@typebot.io/prisma'
|
||||
|
||||
export const executeSetVariable = async (
|
||||
state: SessionState,
|
||||
@ -246,7 +247,7 @@ const toISOWithTz = (date: Date, timeZone: string) => {
|
||||
}
|
||||
|
||||
type ParsedTranscriptProps = {
|
||||
answers: Pick<Answer, 'blockId' | 'content'>[]
|
||||
answers: Pick<Answer, 'blockId' | 'content' | 'attachedFileUrls'>[]
|
||||
setVariableHistory: Pick<
|
||||
SetVariableHistoryItem,
|
||||
'blockId' | 'variableId' | 'value'
|
||||
@ -273,6 +274,10 @@ const parsePreviewTranscriptProps = async (
|
||||
}
|
||||
}
|
||||
|
||||
type UnifiedAnswersFromDB = (ParsedTranscriptProps['answers'][number] & {
|
||||
createdAt: Date
|
||||
})[]
|
||||
|
||||
const parseResultTranscriptProps = async (
|
||||
state: SessionState
|
||||
): Promise<ParsedTranscriptProps | undefined> => {
|
||||
@ -299,6 +304,7 @@ const parseResultTranscriptProps = async (
|
||||
blockId: true,
|
||||
content: true,
|
||||
createdAt: true,
|
||||
attachedFileUrls: true,
|
||||
},
|
||||
},
|
||||
setVariableHistory: {
|
||||
@ -313,8 +319,8 @@ const parseResultTranscriptProps = async (
|
||||
})
|
||||
if (!result) return
|
||||
return {
|
||||
answers: result.answersV2
|
||||
.concat(result.answers)
|
||||
answers: (result.answersV2 as UnifiedAnswersFromDB)
|
||||
.concat(result.answers as UnifiedAnswersFromDB)
|
||||
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()),
|
||||
setVariableHistory: (
|
||||
result.setVariableHistory as SetVariableHistoryItem[]
|
||||
|
@ -4,11 +4,12 @@ import {
|
||||
ContinueChatResponse,
|
||||
Group,
|
||||
InputBlock,
|
||||
Message,
|
||||
SessionState,
|
||||
SetVariableHistoryItem,
|
||||
Variable,
|
||||
} from '@typebot.io/schemas'
|
||||
import { byId } from '@typebot.io/lib'
|
||||
import { byId, isDefined } from '@typebot.io/lib'
|
||||
import { isInputBlock } from '@typebot.io/schemas/helpers'
|
||||
import { executeGroup, parseInput } from './executeGroup'
|
||||
import { getNextGroup } from './getNextGroup'
|
||||
@ -42,10 +43,9 @@ import { ForgedBlock } from '@typebot.io/forge-repository/types'
|
||||
import { forgedBlocks } from '@typebot.io/forge-repository/definitions'
|
||||
import { resumeChatCompletion } from './blocks/integrations/legacy/openai/resumeChatCompletion'
|
||||
import { env } from '@typebot.io/env'
|
||||
import { downloadMedia } from './whatsapp/downloadMedia'
|
||||
import { uploadFileToBucket } from '@typebot.io/lib/s3/uploadFileToBucket'
|
||||
import { isURL } from '@typebot.io/lib/validators/isURL'
|
||||
import { isForgedBlockType } from '@typebot.io/schemas/features/blocks/forged/helpers'
|
||||
import { resetSessionState } from './resetSessionState'
|
||||
|
||||
type Params = {
|
||||
version: 1 | 2
|
||||
@ -69,7 +69,11 @@ export const continueBotFlow = async (
|
||||
const setVariableHistory: SetVariableHistoryItem[] = []
|
||||
|
||||
if (!newSessionState.currentBlockId)
|
||||
return startBotFlow({ state, version, textBubbleContentFormat })
|
||||
return startBotFlow({
|
||||
state: resetSessionState(newSessionState),
|
||||
version,
|
||||
textBubbleContentFormat,
|
||||
})
|
||||
|
||||
const { block, group, blockIndex } = getBlockById(
|
||||
newSessionState.currentBlockId,
|
||||
@ -161,7 +165,7 @@ export const continueBotFlow = async (
|
||||
newVariables: [
|
||||
{
|
||||
...variableToUpdate,
|
||||
value: safeJsonParse(reply as string),
|
||||
value: reply?.text ? safeJsonParse(reply?.text) : undefined,
|
||||
},
|
||||
],
|
||||
})
|
||||
@ -187,7 +191,14 @@ export const continueBotFlow = async (
|
||||
|
||||
formattedReply =
|
||||
'reply' in parsedReplyResult ? parsedReplyResult.reply : undefined
|
||||
newSessionState = await processAndSaveAnswer(state, block)(formattedReply)
|
||||
newSessionState = await processAndSaveAnswer(
|
||||
state,
|
||||
block
|
||||
)(
|
||||
isDefined(formattedReply)
|
||||
? { ...reply, type: 'text', text: formattedReply }
|
||||
: undefined
|
||||
)
|
||||
}
|
||||
|
||||
const groupHasMoreBlocks = blockIndex < group.blocks.length - 1
|
||||
@ -267,37 +278,92 @@ export const continueBotFlow = async (
|
||||
|
||||
const processAndSaveAnswer =
|
||||
(state: SessionState, block: InputBlock) =>
|
||||
async (reply: string | undefined): Promise<SessionState> => {
|
||||
async (reply: Message | undefined): Promise<SessionState> => {
|
||||
if (!reply) return state
|
||||
let newState = await saveAnswerInDb(state, block)(reply)
|
||||
return newState
|
||||
return saveAnswerInDb(state, block)(reply)
|
||||
}
|
||||
|
||||
const saveVariableValueIfAny =
|
||||
const saveVariablesValueIfAny =
|
||||
(state: SessionState, block: InputBlock) =>
|
||||
(reply: string): SessionState => {
|
||||
(reply: Message): SessionState => {
|
||||
if (!block.options?.variableId) return state
|
||||
const foundVariable = state.typebotsQueue[0].typebot.variables.find(
|
||||
(variable) => variable.id === block.options?.variableId
|
||||
)
|
||||
if (!foundVariable) return state
|
||||
|
||||
const { updatedState } = updateVariablesInSession({
|
||||
newVariables: [
|
||||
{
|
||||
...foundVariable,
|
||||
value: Array.isArray(foundVariable.value)
|
||||
? foundVariable.value.concat(reply)
|
||||
: reply,
|
||||
},
|
||||
],
|
||||
currentBlockId: undefined,
|
||||
state,
|
||||
})
|
||||
|
||||
return updatedState
|
||||
const newSessionState = saveAttachmentsVarIfAny({ block, reply, state })
|
||||
return saveInputVarIfAny({ block, reply, state: newSessionState })
|
||||
}
|
||||
|
||||
const saveAttachmentsVarIfAny = ({
|
||||
block,
|
||||
reply,
|
||||
state,
|
||||
}: {
|
||||
block: InputBlock
|
||||
reply: Message
|
||||
state: SessionState
|
||||
}): SessionState => {
|
||||
if (
|
||||
block.type !== InputBlockType.TEXT ||
|
||||
!block.options?.attachments?.isEnabled ||
|
||||
!block.options?.attachments?.saveVariableId ||
|
||||
!reply.attachedFileUrls ||
|
||||
reply.attachedFileUrls.length === 0
|
||||
)
|
||||
return state
|
||||
|
||||
const variable = state.typebotsQueue[0].typebot.variables.find(
|
||||
(variable) => variable.id === block.options?.attachments?.saveVariableId
|
||||
)
|
||||
|
||||
if (!variable) return state
|
||||
|
||||
const { updatedState } = updateVariablesInSession({
|
||||
newVariables: [
|
||||
{
|
||||
id: variable.id,
|
||||
name: variable.name,
|
||||
value: Array.isArray(variable.value)
|
||||
? variable.value.concat(reply.attachedFileUrls)
|
||||
: reply.attachedFileUrls.length === 1
|
||||
? reply.attachedFileUrls[0]
|
||||
: reply.attachedFileUrls,
|
||||
},
|
||||
],
|
||||
currentBlockId: undefined,
|
||||
state,
|
||||
})
|
||||
return updatedState
|
||||
}
|
||||
|
||||
const saveInputVarIfAny = ({
|
||||
block,
|
||||
reply,
|
||||
state,
|
||||
}: {
|
||||
block: InputBlock
|
||||
reply: Message
|
||||
state: SessionState
|
||||
}): SessionState => {
|
||||
const foundVariable = state.typebotsQueue[0].typebot.variables.find(
|
||||
(variable) => variable.id === block.options?.variableId
|
||||
)
|
||||
if (!foundVariable) return state
|
||||
|
||||
const { updatedState } = updateVariablesInSession({
|
||||
newVariables: [
|
||||
{
|
||||
...foundVariable,
|
||||
value:
|
||||
Array.isArray(foundVariable.value) && reply.text
|
||||
? foundVariable.value.concat(reply.text)
|
||||
: reply.text,
|
||||
},
|
||||
],
|
||||
currentBlockId: undefined,
|
||||
state,
|
||||
})
|
||||
|
||||
return updatedState
|
||||
}
|
||||
|
||||
const parseRetryMessage =
|
||||
(state: SessionState) =>
|
||||
async (
|
||||
@ -346,26 +412,27 @@ const parseDefaultRetryMessage = (block: InputBlock): string => {
|
||||
|
||||
const saveAnswerInDb =
|
||||
(state: SessionState, block: InputBlock) =>
|
||||
async (reply: string): Promise<SessionState> => {
|
||||
async (reply: Message): Promise<SessionState> => {
|
||||
let newSessionState = state
|
||||
await saveAnswer({
|
||||
answer: {
|
||||
blockId: block.id,
|
||||
content: reply,
|
||||
content: reply.text,
|
||||
attachedFileUrls: reply.attachedFileUrls,
|
||||
},
|
||||
reply,
|
||||
state,
|
||||
})
|
||||
|
||||
newSessionState = {
|
||||
...saveVariableValueIfAny(newSessionState, block)(reply),
|
||||
...saveVariablesValueIfAny(newSessionState, block)(reply),
|
||||
previewMetadata: state.typebotsQueue[0].resultId
|
||||
? newSessionState.previewMetadata
|
||||
: {
|
||||
...newSessionState.previewMetadata,
|
||||
answers: (newSessionState.previewMetadata?.answers ?? []).concat({
|
||||
blockId: block.id,
|
||||
content: reply,
|
||||
content: reply.text,
|
||||
attachedFileUrls: reply.attachedFileUrls,
|
||||
}),
|
||||
},
|
||||
}
|
||||
@ -378,7 +445,10 @@ const saveAnswerInDb =
|
||||
|
||||
return setNewAnswerInState(newSessionState)({
|
||||
key: key ?? block.id,
|
||||
value: reply,
|
||||
value:
|
||||
(reply.attachedFileUrls ?? []).length > 0
|
||||
? `${reply.attachedFileUrls!.join(', ')}\n\n${reply.text}`
|
||||
: reply.text,
|
||||
})
|
||||
}
|
||||
|
||||
@ -465,41 +535,17 @@ const getOutgoingEdgeId =
|
||||
const parseReply =
|
||||
(state: SessionState) =>
|
||||
async (reply: Reply, block: InputBlock): Promise<ParsedReply> => {
|
||||
if (reply && typeof reply !== 'string') {
|
||||
if (block.type !== InputBlockType.FILE) return { status: 'fail' }
|
||||
if (block.options?.visibility !== 'Public') {
|
||||
return {
|
||||
status: 'success',
|
||||
reply:
|
||||
env.NEXTAUTH_URL +
|
||||
`/api/typebots/${state.typebotsQueue[0].typebot.id}/whatsapp/media/${reply.mediaId}`,
|
||||
}
|
||||
}
|
||||
const { file, mimeType } = await downloadMedia({
|
||||
mediaId: reply.mediaId,
|
||||
systemUserAccessToken: reply.accessToken,
|
||||
})
|
||||
const url = await uploadFileToBucket({
|
||||
file,
|
||||
key: `public/workspaces/${reply.workspaceId}/typebots/${state.typebotsQueue[0].typebot.id}/results/${state.typebotsQueue[0].resultId}/${reply.mediaId}`,
|
||||
mimeType,
|
||||
})
|
||||
return {
|
||||
status: 'success',
|
||||
reply: url,
|
||||
}
|
||||
}
|
||||
switch (block.type) {
|
||||
case InputBlockType.EMAIL: {
|
||||
if (!reply) return { status: 'fail' }
|
||||
const formattedEmail = formatEmail(reply)
|
||||
const formattedEmail = formatEmail(reply.text)
|
||||
if (!formattedEmail) return { status: 'fail' }
|
||||
return { status: 'success', reply: formattedEmail }
|
||||
}
|
||||
case InputBlockType.PHONE: {
|
||||
if (!reply) return { status: 'fail' }
|
||||
const formattedPhone = formatPhoneNumber(
|
||||
reply,
|
||||
reply.text,
|
||||
block.options?.defaultCountryCode
|
||||
)
|
||||
if (!formattedPhone) return { status: 'fail' }
|
||||
@ -507,58 +553,60 @@ const parseReply =
|
||||
}
|
||||
case InputBlockType.URL: {
|
||||
if (!reply) return { status: 'fail' }
|
||||
const isValid = isURL(reply, { require_protocol: false })
|
||||
const isValid = isURL(reply.text, { require_protocol: false })
|
||||
if (!isValid) return { status: 'fail' }
|
||||
return { status: 'success', reply: reply }
|
||||
return { status: 'success', reply: reply.text }
|
||||
}
|
||||
case InputBlockType.CHOICE: {
|
||||
if (!reply) return { status: 'fail' }
|
||||
return parseButtonsReply(state)(reply, block)
|
||||
return parseButtonsReply(state)(reply.text, block)
|
||||
}
|
||||
case InputBlockType.NUMBER: {
|
||||
if (!reply) return { status: 'fail' }
|
||||
const isValid = validateNumber(reply, {
|
||||
const isValid = validateNumber(reply.text, {
|
||||
options: block.options,
|
||||
variables: state.typebotsQueue[0].typebot.variables,
|
||||
})
|
||||
if (!isValid) return { status: 'fail' }
|
||||
return { status: 'success', reply: parseNumber(reply) }
|
||||
return { status: 'success', reply: parseNumber(reply.text) }
|
||||
}
|
||||
case InputBlockType.DATE: {
|
||||
if (!reply) return { status: 'fail' }
|
||||
return parseDateReply(reply, block)
|
||||
return parseDateReply(reply.text, block)
|
||||
}
|
||||
case InputBlockType.FILE: {
|
||||
if (!reply)
|
||||
return block.options?.isRequired ?? defaultFileInputOptions.isRequired
|
||||
? { status: 'fail' }
|
||||
: { status: 'skip' }
|
||||
const urls = reply.split(', ')
|
||||
const urls = reply.text.split(', ')
|
||||
const status = urls.some((url) =>
|
||||
isURL(url, { require_tld: env.S3_ENDPOINT !== 'localhost' })
|
||||
)
|
||||
? 'success'
|
||||
: 'fail'
|
||||
return { status, reply: reply }
|
||||
if (!block.options?.isMultipleAllowed && urls.length > 1)
|
||||
return { status, reply: reply.text.split(',')[0] }
|
||||
return { status, reply: reply.text }
|
||||
}
|
||||
case InputBlockType.PAYMENT: {
|
||||
if (!reply) return { status: 'fail' }
|
||||
if (reply === 'fail') return { status: 'fail' }
|
||||
return { status: 'success', reply: reply }
|
||||
if (reply.text === 'fail') return { status: 'fail' }
|
||||
return { status: 'success', reply: reply.text }
|
||||
}
|
||||
case InputBlockType.RATING: {
|
||||
if (!reply) return { status: 'fail' }
|
||||
const isValid = validateRatingReply(reply, block)
|
||||
const isValid = validateRatingReply(reply.text, block)
|
||||
if (!isValid) return { status: 'fail' }
|
||||
return { status: 'success', reply: reply }
|
||||
return { status: 'success', reply: reply.text }
|
||||
}
|
||||
case InputBlockType.PICTURE_CHOICE: {
|
||||
if (!reply) return { status: 'fail' }
|
||||
return parsePictureChoicesReply(state)(reply, block)
|
||||
return parsePictureChoicesReply(state)(reply.text, block)
|
||||
}
|
||||
case InputBlockType.TEXT: {
|
||||
if (!reply) return { status: 'fail' }
|
||||
return { status: 'success', reply: reply }
|
||||
return { status: 'success', reply: reply.text }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -4,7 +4,6 @@ import { SessionState } from '@typebot.io/schemas'
|
||||
|
||||
type Props = {
|
||||
answer: Omit<Prisma.AnswerV2CreateManyInput, 'resultId'>
|
||||
reply: string
|
||||
state: SessionState
|
||||
}
|
||||
export const saveAnswer = async ({ answer, state }: Props) => {
|
||||
|
20
packages/bot-engine/resetSessionState.ts
Normal file
20
packages/bot-engine/resetSessionState.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { SessionState } from '@typebot.io/schemas/features/chat/sessionState'
|
||||
|
||||
export const resetSessionState = (state: SessionState): SessionState => ({
|
||||
...state,
|
||||
currentSetVariableHistoryIndex: undefined,
|
||||
currentVisitedEdgeIndex: undefined,
|
||||
previewMetadata: undefined,
|
||||
progressMetadata: undefined,
|
||||
typebotsQueue: state.typebotsQueue.map((queueItem) => ({
|
||||
...queueItem,
|
||||
answers: [],
|
||||
typebot: {
|
||||
...queueItem.typebot,
|
||||
variables: queueItem.typebot.variables.map((variable) => ({
|
||||
...variable,
|
||||
value: undefined,
|
||||
})),
|
||||
},
|
||||
})),
|
||||
})
|
@ -61,14 +61,12 @@ type StartParams =
|
||||
|
||||
type Props = {
|
||||
version: 1 | 2
|
||||
message: Reply
|
||||
startParams: StartParams
|
||||
initialSessionState?: Pick<SessionState, 'whatsApp' | 'expiryTimeout'>
|
||||
}
|
||||
|
||||
export const startSession = async ({
|
||||
version,
|
||||
message,
|
||||
startParams,
|
||||
initialSessionState,
|
||||
}: Props): Promise<
|
||||
@ -188,7 +186,7 @@ export const startSession = async ({
|
||||
})
|
||||
|
||||
// If params has message and first block is an input block, we can directly continue the bot flow
|
||||
if (message) {
|
||||
if (startParams.message) {
|
||||
const firstEdgeId = getFirstEdgeId({
|
||||
typebot: chatReply.newSessionState.typebotsQueue[0].typebot,
|
||||
startEventId:
|
||||
@ -213,7 +211,7 @@ export const startSession = async ({
|
||||
resultId,
|
||||
typebot: newSessionState.typebotsQueue[0].typebot,
|
||||
})
|
||||
chatReply = await continueBotFlow(message, {
|
||||
chatReply = await continueBotFlow(startParams.message, {
|
||||
version,
|
||||
state: {
|
||||
...newSessionState,
|
||||
|
@ -1,6 +1,7 @@
|
||||
import {
|
||||
ContinueChatResponse,
|
||||
CustomEmbedBubble,
|
||||
Message,
|
||||
SessionState,
|
||||
SetVariableHistoryItem,
|
||||
} from '@typebot.io/schemas'
|
||||
@ -21,14 +22,7 @@ export type ExecuteIntegrationResponse = {
|
||||
newSetVariableHistory?: SetVariableHistoryItem[]
|
||||
} & Pick<ContinueChatResponse, 'clientSideActions' | 'logs'>
|
||||
|
||||
type WhatsAppMediaMessage = {
|
||||
type: 'whatsapp media'
|
||||
mediaId: string
|
||||
workspaceId?: string
|
||||
accessToken: string
|
||||
}
|
||||
|
||||
export type Reply = string | WhatsAppMediaMessage | undefined
|
||||
export type Reply = Message | undefined
|
||||
|
||||
export type ParsedReply =
|
||||
| { status: 'success'; reply: string }
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { SessionState } from '@typebot.io/schemas'
|
||||
import { Block, SessionState } from '@typebot.io/schemas'
|
||||
import {
|
||||
WhatsAppCredentials,
|
||||
WhatsAppIncomingMessage,
|
||||
@ -15,6 +15,13 @@ import { isDefined } from '@typebot.io/lib/utils'
|
||||
import { Reply } from '../types'
|
||||
import { setIsReplyingInChatSession } from '../queries/setIsReplyingInChatSession'
|
||||
import { removeIsReplyingInChatSession } from '../queries/removeIsReplyingInChatSession'
|
||||
import redis from '@typebot.io/lib/redis'
|
||||
import { downloadMedia } from './downloadMedia'
|
||||
import { InputBlockType } from '@typebot.io/schemas/features/blocks/inputs/constants'
|
||||
import { uploadFileToBucket } from '@typebot.io/lib/s3/uploadFileToBucket'
|
||||
import { getBlockById } from '@typebot.io/schemas/helpers'
|
||||
|
||||
const incomingMessageDebounce = 3000
|
||||
|
||||
type Props = {
|
||||
receivedMessage: WhatsAppIncomingMessage
|
||||
@ -61,33 +68,59 @@ export const resumeWhatsAppFlow = async ({
|
||||
}
|
||||
}
|
||||
|
||||
const reply = await getIncomingMessageContent({
|
||||
message: receivedMessage,
|
||||
workspaceId,
|
||||
accessToken: credentials?.systemUserAccessToken,
|
||||
})
|
||||
|
||||
const session = await getSession(sessionId)
|
||||
|
||||
const { incomingMessages, isReplyingWasSet } =
|
||||
await aggregateParallelMediaMessagesIfRedisEnabled({
|
||||
receivedMessage,
|
||||
existingSessionId: session?.id,
|
||||
newSessionId: sessionId,
|
||||
})
|
||||
|
||||
if (incomingMessages.length === 0) {
|
||||
if (isReplyingWasSet) await removeIsReplyingInChatSession(sessionId)
|
||||
|
||||
return {
|
||||
message: 'Message received',
|
||||
}
|
||||
}
|
||||
|
||||
const isSessionExpired =
|
||||
session &&
|
||||
isDefined(session.state.expiryTimeout) &&
|
||||
session?.updatedAt.getTime() + session.state.expiryTimeout < Date.now()
|
||||
|
||||
if (session?.isReplying) {
|
||||
if (!isSessionExpired) {
|
||||
console.log('Is currently replying, skipping...')
|
||||
return {
|
||||
message: 'Message received',
|
||||
if (!isReplyingWasSet) {
|
||||
if (session?.isReplying) {
|
||||
if (!isSessionExpired) {
|
||||
console.log('Is currently replying, skipping...')
|
||||
return {
|
||||
message: 'Message received',
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await setIsReplyingInChatSession({
|
||||
existingSessionId: session?.id,
|
||||
newSessionId: sessionId,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
await setIsReplyingInChatSession({
|
||||
existingSessionId: session?.id,
|
||||
newSessionId: sessionId,
|
||||
})
|
||||
}
|
||||
|
||||
const currentTypebot = session?.state.typebotsQueue[0].typebot
|
||||
const { block } =
|
||||
(currentTypebot && session?.state.currentBlockId
|
||||
? getBlockById(session.state.currentBlockId, currentTypebot.groups)
|
||||
: undefined) ?? {}
|
||||
|
||||
const reply = await getIncomingMessageContent({
|
||||
messages: incomingMessages,
|
||||
workspaceId,
|
||||
accessToken: credentials?.systemUserAccessToken,
|
||||
typebotId: currentTypebot?.id,
|
||||
resultId: session?.state.typebotsQueue[0].resultId,
|
||||
block,
|
||||
})
|
||||
|
||||
const resumeResponse =
|
||||
session && !isSessionExpired
|
||||
? await continueBotFlow(reply, {
|
||||
@ -155,35 +188,107 @@ export const resumeWhatsAppFlow = async ({
|
||||
}
|
||||
|
||||
const getIncomingMessageContent = async ({
|
||||
message,
|
||||
messages,
|
||||
workspaceId,
|
||||
accessToken,
|
||||
typebotId,
|
||||
resultId,
|
||||
block,
|
||||
}: {
|
||||
message: WhatsAppIncomingMessage
|
||||
messages: WhatsAppIncomingMessage[]
|
||||
workspaceId?: string
|
||||
accessToken: string
|
||||
typebotId?: string
|
||||
resultId?: string
|
||||
block?: Block
|
||||
}): Promise<Reply> => {
|
||||
switch (message.type) {
|
||||
case 'text':
|
||||
return message.text.body
|
||||
case 'button':
|
||||
return message.button.text
|
||||
case 'interactive': {
|
||||
return message.interactive.button_reply.id
|
||||
let text: string = ''
|
||||
const attachedFileUrls: string[] = []
|
||||
for (const message of messages) {
|
||||
switch (message.type) {
|
||||
case 'text': {
|
||||
if (text !== '') text += `\n\n${message.text.body}`
|
||||
else text = message.text.body
|
||||
break
|
||||
}
|
||||
case 'button': {
|
||||
if (text !== '') text += `\n\n${message.button.text}`
|
||||
else text = message.button.text
|
||||
break
|
||||
}
|
||||
case 'interactive': {
|
||||
if (text !== '') text += `\n\n${message.interactive.button_reply.id}`
|
||||
else text = message.interactive.button_reply.id
|
||||
break
|
||||
}
|
||||
case 'document':
|
||||
case 'audio':
|
||||
case 'video':
|
||||
case 'image': {
|
||||
let mediaId: string | undefined
|
||||
if (message.type === 'video') mediaId = message.video.id
|
||||
if (message.type === 'image') mediaId = message.image.id
|
||||
if (message.type === 'audio') mediaId = message.audio.id
|
||||
if (message.type === 'document') mediaId = message.document.id
|
||||
if (!mediaId) return
|
||||
const fileVisibility =
|
||||
block?.type === InputBlockType.FILE
|
||||
? block.options?.visibility
|
||||
: block?.type === InputBlockType.TEXT
|
||||
? block.options?.attachments?.visibility
|
||||
: undefined
|
||||
let fileUrl
|
||||
if (fileVisibility !== 'Public') {
|
||||
fileUrl =
|
||||
env.NEXTAUTH_URL +
|
||||
`/api/typebots/${typebotId}/whatsapp/media/${
|
||||
workspaceId ? `` : 'preview/'
|
||||
}${mediaId}`
|
||||
} else {
|
||||
const { file, mimeType } = await downloadMedia({
|
||||
mediaId,
|
||||
systemUserAccessToken: accessToken,
|
||||
})
|
||||
const url = await uploadFileToBucket({
|
||||
file,
|
||||
key:
|
||||
resultId && workspaceId && typebotId
|
||||
? `public/workspaces/${workspaceId}/typebots/${typebotId}/results/${resultId}/${mediaId}`
|
||||
: `tmp/whatsapp/media/${mediaId}`,
|
||||
mimeType,
|
||||
})
|
||||
fileUrl = url
|
||||
}
|
||||
if (block?.type === InputBlockType.FILE) {
|
||||
if (text !== '') text += `, ${fileUrl}`
|
||||
else text = fileUrl
|
||||
} else if (block?.type === InputBlockType.TEXT) {
|
||||
let caption: string | undefined
|
||||
if (message.type === 'document' && message.document.caption) {
|
||||
if (!/^[\w,\s-]+\.[A-Za-z]{3}$/.test(message.document.caption))
|
||||
caption = message.document.caption
|
||||
} else if (message.type === 'image' && message.image.caption)
|
||||
caption = message.image.caption
|
||||
else if (message.type === 'video' && message.video.caption)
|
||||
caption = message.video.caption
|
||||
if (caption) text = text === '' ? caption : `${text}\n\n${caption}`
|
||||
attachedFileUrls.push(fileUrl)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'location': {
|
||||
const location = `${message.location.latitude}, ${message.location.longitude}`
|
||||
if (text !== '') text += `\n\n${location}`
|
||||
else text = location
|
||||
break
|
||||
}
|
||||
}
|
||||
case 'document':
|
||||
case 'audio':
|
||||
case 'video':
|
||||
case 'image':
|
||||
let mediaId: string | undefined
|
||||
if (message.type === 'video') mediaId = message.video.id
|
||||
if (message.type === 'image') mediaId = message.image.id
|
||||
if (message.type === 'audio') mediaId = message.audio.id
|
||||
if (message.type === 'document') mediaId = message.document.id
|
||||
if (!mediaId) return
|
||||
return { type: 'whatsapp media', mediaId, workspaceId, accessToken }
|
||||
case 'location':
|
||||
return `${message.location.latitude}, ${message.location.longitude}`
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'text',
|
||||
text,
|
||||
attachedFileUrls,
|
||||
}
|
||||
}
|
||||
|
||||
@ -227,3 +332,57 @@ const getCredentials = async ({
|
||||
phoneNumberId: data.phoneNumberId,
|
||||
}
|
||||
}
|
||||
|
||||
const aggregateParallelMediaMessagesIfRedisEnabled = async ({
|
||||
receivedMessage,
|
||||
existingSessionId,
|
||||
newSessionId,
|
||||
}: {
|
||||
receivedMessage: WhatsAppIncomingMessage
|
||||
existingSessionId?: string
|
||||
newSessionId: string
|
||||
}): Promise<{
|
||||
isReplyingWasSet: boolean
|
||||
incomingMessages: WhatsAppIncomingMessage[]
|
||||
}> => {
|
||||
if (redis && ['document', 'video', 'image'].includes(receivedMessage.type)) {
|
||||
const redisKey = `wasession:${newSessionId}`
|
||||
try {
|
||||
const len = await redis.rpush(redisKey, JSON.stringify(receivedMessage))
|
||||
|
||||
if (len === 1) {
|
||||
await setIsReplyingInChatSession({
|
||||
existingSessionId,
|
||||
newSessionId,
|
||||
})
|
||||
}
|
||||
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, incomingMessageDebounce)
|
||||
)
|
||||
|
||||
const newMessagesResponse = await redis.lrange(redisKey, 0, -1)
|
||||
|
||||
if (!newMessagesResponse || newMessagesResponse.length > len) {
|
||||
// Current message was aggregated with other messages another webhook handler. Skipping...
|
||||
return { isReplyingWasSet: true, incomingMessages: [] }
|
||||
}
|
||||
|
||||
redis.del(redisKey).then()
|
||||
|
||||
return {
|
||||
isReplyingWasSet: true,
|
||||
incomingMessages: newMessagesResponse.map((msgStr) =>
|
||||
JSON.parse(msgStr)
|
||||
),
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to process webhook event:', error, receivedMessage)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isReplyingWasSet: false,
|
||||
incomingMessages: [receivedMessage],
|
||||
}
|
||||
}
|
||||
|
@ -58,11 +58,16 @@ export const sendChatReplyToWhatsApp = async ({
|
||||
const result = await executeClientSideAction({ to, credentials })(action)
|
||||
if (!result) continue
|
||||
const { input, newSessionState, messages, clientSideActions } =
|
||||
await continueBotFlow(result.replyToSend, {
|
||||
version: 2,
|
||||
state,
|
||||
textBubbleContentFormat: 'richText',
|
||||
})
|
||||
await continueBotFlow(
|
||||
result.replyToSend
|
||||
? { type: 'text', text: result.replyToSend }
|
||||
: undefined,
|
||||
{
|
||||
version: 2,
|
||||
state,
|
||||
textBubbleContentFormat: 'richText',
|
||||
}
|
||||
)
|
||||
|
||||
return sendChatReplyToWhatsApp({
|
||||
to,
|
||||
@ -128,11 +133,16 @@ export const sendChatReplyToWhatsApp = async ({
|
||||
)
|
||||
if (!result) continue
|
||||
const { input, newSessionState, messages, clientSideActions } =
|
||||
await continueBotFlow(result.replyToSend, {
|
||||
version: 2,
|
||||
state,
|
||||
textBubbleContentFormat: 'richText',
|
||||
})
|
||||
await continueBotFlow(
|
||||
result.replyToSend
|
||||
? { type: 'text', text: result.replyToSend }
|
||||
: undefined,
|
||||
{
|
||||
version: 2,
|
||||
state,
|
||||
textBubbleContentFormat: 'richText',
|
||||
}
|
||||
)
|
||||
|
||||
return sendChatReplyToWhatsApp({
|
||||
to,
|
||||
|
@ -68,7 +68,7 @@ export const startWhatsAppSession = async ({
|
||||
(publicTypebot.settings.whatsApp?.startCondition?.comparisons.length ??
|
||||
0) > 0 &&
|
||||
messageMatchStartCondition(
|
||||
incomingMessage ?? '',
|
||||
incomingMessage ?? { type: 'text', text: '' },
|
||||
publicTypebot.settings.whatsApp?.startCondition
|
||||
)
|
||||
)
|
||||
@ -90,13 +90,13 @@ export const startWhatsAppSession = async ({
|
||||
|
||||
return startSession({
|
||||
version: 2,
|
||||
message: incomingMessage,
|
||||
startParams: {
|
||||
type: 'live',
|
||||
publicId: publicTypebot.typebot.publicId as string,
|
||||
isOnlyRegistering: false,
|
||||
isStreamEnabled: false,
|
||||
textBubbleContentFormat: 'richText',
|
||||
message: incomingMessage,
|
||||
},
|
||||
initialSessionState: {
|
||||
whatsApp: {
|
||||
|
Reference in New Issue
Block a user