♻️ Export bot-engine code into its own package
This commit is contained in:
@@ -1,25 +0,0 @@
|
||||
import {
|
||||
TypingEmulation,
|
||||
defaultSettings,
|
||||
} from '@typebot.io/schemas/features/typebot/settings'
|
||||
|
||||
type Props = {
|
||||
bubbleContent: string
|
||||
typingSettings?: TypingEmulation
|
||||
}
|
||||
|
||||
export const computeTypingDuration = ({
|
||||
bubbleContent,
|
||||
typingSettings = defaultSettings({ isBrandingEnabled: false })
|
||||
.typingEmulation,
|
||||
}: Props) => {
|
||||
let wordCount = bubbleContent.match(/(\w+)/g)?.length ?? 0
|
||||
if (wordCount === 0) wordCount = bubbleContent.length
|
||||
const typedWordsPerMinute = typingSettings.speed
|
||||
let typingTimeout = typingSettings.enabled
|
||||
? (wordCount / typedWordsPerMinute) * 60000
|
||||
: 0
|
||||
if (typingTimeout > typingSettings.maxDelay * 1000)
|
||||
typingTimeout = typingSettings.maxDelay * 1000
|
||||
return typingTimeout
|
||||
}
|
||||
3
packages/lib/isPlanetScale.ts
Normal file
3
packages/lib/isPlanetScale.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { env } from '@typebot.io/env'
|
||||
|
||||
export const isPlaneteScale = () => env.DATABASE_URL?.includes('pscale_pw')
|
||||
@@ -23,6 +23,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@sentry/nextjs": "7.66.0",
|
||||
"@trpc/server": "10.34.0",
|
||||
"@udecode/plate-common": "^21.1.5",
|
||||
"got": "12.6.0",
|
||||
"minio": "7.1.3",
|
||||
|
||||
16
packages/lib/prisma.ts
Normal file
16
packages/lib/prisma.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { env } from '@typebot.io/env'
|
||||
import { PrismaClient } from '@typebot.io/prisma'
|
||||
|
||||
declare const global: { prisma: PrismaClient }
|
||||
let prisma: PrismaClient
|
||||
|
||||
if (env.NODE_ENV === 'production') {
|
||||
prisma = new PrismaClient()
|
||||
} else {
|
||||
if (!global.prisma) {
|
||||
global.prisma = new PrismaClient()
|
||||
}
|
||||
prisma = global.prisma
|
||||
}
|
||||
|
||||
export default prisma
|
||||
@@ -3,6 +3,6 @@
|
||||
"include": ["**/*.ts"],
|
||||
"exclude": ["node_modules"],
|
||||
"compilerOptions": {
|
||||
"target": "ES2021"
|
||||
"lib": ["ES2021", "DOM"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
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,
|
||||
[]
|
||||
)
|
||||
@@ -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 '../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')
|
||||
}
|
||||
@@ -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('&#39;', "'")
|
||||
)
|
||||
.join('')
|
||||
@@ -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 { 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
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user