2
0

(whatsapp) Improve WhatsApp preview management

Closes #800
This commit is contained in:
Baptiste Arnaud
2023-09-19 11:53:18 +02:00
parent 2ce63f5d06
commit f626c9867c
29 changed files with 830 additions and 681 deletions

View File

@@ -1,5 +1,5 @@
{
"name": "viewer",
"name": "@typebot.io/viewer",
"license": "AGPL-3.0-or-later",
"version": "0.1.0",
"scripts": {

View File

@@ -79,7 +79,7 @@ const getExpressionToEvaluate =
case 'Contact name':
return state.whatsApp?.contact.name ?? ''
case 'Phone number':
return state.whatsApp?.contact.phoneNumber ?? ''
return `"${state.whatsApp?.contact.phoneNumber}"` ?? ''
case 'Now':
case 'Today':
return 'new Date().toISOString()'

View File

@@ -9,7 +9,8 @@ export const receiveMessage = publicProcedure
openapi: {
method: 'POST',
path: '/workspaces/{workspaceId}/whatsapp/phoneNumbers/{phoneNumberId}/webhook',
summary: 'Receive WhatsApp Message',
summary: 'Message webhook',
tags: ['WhatsApp'],
},
})
.input(
@@ -28,7 +29,7 @@ export const receiveMessage = publicProcedure
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 ?? ''
entry.at(0)?.changes.at(0)?.value?.messages?.at(0)?.from ?? ''
return resumeWhatsAppFlow({
receivedMessage,
sessionId: `wa-${phoneNumberId}-${receivedMessage.from}`,

View File

@@ -1,44 +0,0 @@
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

@@ -1,14 +1,8 @@
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

@@ -1,141 +0,0 @@
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({
startParams: {
isOnlyRegistering: !canSendDirectMessagesToUser,
typebot: typebotId,
isPreview: true,
startGroupId,
},
userId: user.id,
})
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

@@ -1,29 +0,0 @@
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

@@ -8,7 +8,8 @@ export const subscribeWebhook = publicProcedure
openapi: {
method: 'GET',
path: '/workspaces/{workspaceId}/whatsapp/phoneNumbers/{phoneNumberId}/webhook',
summary: 'Subscribe WhatsApp webhook',
summary: 'Subscribe webhook',
tags: ['WhatsApp'],
protect: true,
},
})

View File

@@ -1,138 +0,0 @@
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

@@ -1,84 +0,0 @@
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

@@ -1,9 +0,0 @@
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

@@ -11,7 +11,7 @@ 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'
import { sendChatReplyToWhatsApp } from '@typebot.io/lib/whatsApp/sendChatReplyToWhatsApp'
export const resumeWhatsAppFlow = async ({
receivedMessage,

View File

@@ -1,162 +0,0 @@
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

@@ -1,28 +0,0 @@
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,
},
})