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

@@ -8,21 +8,24 @@
"devDependencies": {
"@paralleldrive/cuid2": "2.2.1",
"@playwright/test": "1.36.0",
"@typebot.io/env": "workspace:*",
"@typebot.io/prisma": "workspace:*",
"@typebot.io/schemas": "workspace:*",
"@typebot.io/tsconfig": "workspace:*",
"@types/nodemailer": "6.4.8",
"next": "13.4.3",
"nodemailer": "6.9.3",
"typescript": "5.1.6",
"@typebot.io/env": "workspace:*"
"typescript": "5.1.6"
},
"peerDependencies": {
"next": "13.0.0",
"nodemailer": "6.7.8"
},
"dependencies": {
"@sentry/nextjs": "7.66.0",
"@udecode/plate-common": "^21.1.5",
"got": "12.6.0",
"minio": "7.1.3"
"minio": "7.1.3",
"remark-slate": "^1.8.6"
}
}

View File

@@ -1,5 +1,8 @@
{
"extends": "@typebot.io/tsconfig/base.json",
"include": ["**/*.ts"],
"exclude": ["node_modules"]
"exclude": ["node_modules"],
"compilerOptions": {
"target": "ES2021"
}
}

View File

@@ -0,0 +1,138 @@
import {
BubbleBlockType,
ButtonItem,
ChatReply,
InputBlockType,
} from '@typebot.io/schemas'
import { WhatsAppSendingMessage } from '@typebot.io/schemas/features/whatsapp'
import { convertRichTextToWhatsAppText } from './convertRichTextToWhatsAppText'
import { isDefined, isEmpty } from '../utils'
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 '../utils'
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,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 { HTTPError } from 'got'
import { convertInputToWhatsAppMessages } from './convertInputToWhatsAppMessage'
import { isNotDefined } from '../utils'
import { computeTypingDuration } from '../computeTypingDuration'
// 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,
},
})