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

@@ -1,6 +1,6 @@
{
"name": "@typebot.io/js",
"version": "0.1.19",
"version": "0.1.20",
"description": "Javascript library to display typebots on your website",
"type": "module",
"main": "dist/index.js",

View File

@@ -5,6 +5,7 @@ import {
createSignal,
createUniqueId,
For,
onCleanup,
onMount,
Show,
} from 'solid-js'
@@ -268,6 +269,11 @@ export const ConversationContainer = (props: Props) => {
}
}
onCleanup(() => {
setStreamingMessage(undefined)
setFormattedMessages([])
})
const handleSkip = () => sendMessage(undefined)
return (

View File

@@ -1,11 +1,11 @@
import { TypingBubble } from '@/components'
import type { TextBubbleContent, TypingEmulation } from '@typebot.io/schemas'
import { For, createSignal, onCleanup, onMount } from 'solid-js'
import { computeTypingDuration } from '../helpers/computeTypingDuration'
import { PlateBlock } from './plate/PlateBlock'
import { computePlainText } from '../helpers/convertRichTextToPlainText'
import { clsx } from 'clsx'
import { isMobile } from '@/utils/isMobileSignal'
import { computeTypingDuration } from '@typebot.io/lib/computeTypingDuration'
type Props = {
content: TextBubbleContent
@@ -15,12 +15,6 @@ type Props = {
export const showAnimationDuration = 400
const defaultTypingEmulation = {
enabled: true,
speed: 300,
maxDelay: 1.5,
}
let typingTimeout: NodeJS.Timeout
export const TextBubble = (props: Props) => {
@@ -41,10 +35,10 @@ export const TextBubble = (props: Props) => {
const typingDuration =
props.typingEmulation?.enabled === false
? 0
: computeTypingDuration(
plainText,
props.typingEmulation ?? defaultTypingEmulation
)
: computeTypingDuration({
bubbleContent: plainText,
typingSettings: props.typingEmulation,
})
typingTimeout = setTimeout(onTypingEnd, typingDuration)
})

View File

@@ -1,6 +1,6 @@
{
"name": "@typebot.io/nextjs",
"version": "0.1.19",
"version": "0.1.20",
"description": "Convenient library to display typebots on your Next.js website",
"main": "dist/index.js",
"types": "dist/index.d.ts",

View File

@@ -1,6 +1,6 @@
{
"name": "@typebot.io/react",
"version": "0.1.19",
"version": "0.1.20",
"description": "Convenient library to display typebots on your React app",
"main": "dist/index.js",
"types": "dist/index.d.ts",

26
packages/env/env.ts vendored
View File

@@ -258,8 +258,17 @@ const telemetryEnv = {
}
const posthogEnv = {
server: {
POSTHOG_API_KEY: z.string().min(1).optional(),
client: {
NEXT_PUBLIC_POSTHOG_KEY: z.string().min(1).optional(),
NEXT_PUBLIC_POSTHOG_HOST: z
.string()
.min(1)
.optional()
.default('https://app.posthog.com'),
},
runtimeEnv: {
NEXT_PUBLIC_POSTHOG_KEY: getRuntimeVariable('NEXT_PUBLIC_POSTHOG_KEY'),
NEXT_PUBLIC_POSTHOG_HOST: getRuntimeVariable('NEXT_PUBLIC_POSTHOG_HOST'),
},
}
@@ -281,7 +290,6 @@ export const env = createEnv({
...customOAuthEnv.server,
...sentryEnv.server,
...telemetryEnv.server,
...posthogEnv.server,
},
client: {
...baseEnv.client,
@@ -292,6 +300,7 @@ export const env = createEnv({
...vercelEnv.client,
...unsplashEnv.client,
...sentryEnv.client,
...posthogEnv.client,
},
experimental__runtimeEnv: {
...baseEnv.runtimeEnv,
@@ -302,10 +311,11 @@ export const env = createEnv({
...vercelEnv.runtimeEnv,
...unsplashEnv.runtimeEnv,
...sentryEnv.runtimeEnv,
...posthogEnv.runtimeEnv,
},
onInvalidAccess: (variable: string) => {
throw new Error(
`❌ Attempted to access a server-side environment variable on the client: ${variable}`
)
},
// onInvalidAccess: (variable: string) => {
// throw new Error(
// `❌ Attempted to access a server-side environment variable on the client: ${variable}`
// )
// },
})

View File

@@ -0,0 +1,31 @@
import { env } from '@typebot.io/env'
import { Client } from 'minio'
export const deleteFilesFromBucket = async ({
urls,
}: {
urls: string[]
}): Promise<void> => {
if (!env.S3_ENDPOINT || !env.S3_ACCESS_KEY || !env.S3_SECRET_KEY)
throw new Error(
'S3 not properly configured. Missing one of those variables: S3_ENDPOINT, S3_ACCESS_KEY, S3_SECRET_KEY'
)
const minioClient = new Client({
endPoint: env.S3_ENDPOINT,
port: env.S3_PORT,
useSSL: env.S3_SSL ?? true,
accessKey: env.S3_ACCESS_KEY,
secretKey: env.S3_SECRET_KEY,
region: env.S3_REGION,
})
const bucket = env.S3_BUCKET ?? 'typebot'
return minioClient.removeObjects(
bucket,
urls
.filter((url) => url.includes(env.S3_ENDPOINT as string))
.map((url) => url.split(`/${bucket}/`)[1])
)
}

View File

@@ -24,9 +24,9 @@ export const generatePresignedUrl = ({
accessKeyId: env.S3_ACCESS_KEY,
secretAccessKey: env.S3_SECRET_KEY,
region: env.S3_REGION,
sslEnabled: env.S3_SSL,
sslEnabled: env.S3_SSL ?? true,
})
const protocol = env.S3_SSL ? 'https' : 'http'
const protocol = env.S3_SSL ?? true ? 'https' : 'http'
const s3 = new S3({
endpoint: new Endpoint(
`${protocol}://${env.S3_ENDPOINT}${env.S3_PORT ? `:${env.S3_PORT}` : ''}`

View File

@@ -104,7 +104,7 @@ const deleteFilesFromBucket = async ({
const minioClient = new Client({
endPoint: env.S3_ENDPOINT,
port: env.S3_PORT,
useSSL: env.S3_SSL,
useSSL: env.S3_SSL ?? true,
accessKey: env.S3_ACCESS_KEY,
secretKey: env.S3_SECRET_KEY,
region: env.S3_REGION,

View File

@@ -1,3 +1,3 @@
export * from './utils'
export * from './storage'
export * from './generatePresignedUrl'
export * from './encryption'

View File

@@ -0,0 +1,36 @@
import { env } from '@typebot.io/env'
import { Client } from 'minio'
type Props = {
fileName: string
file: Buffer
mimeType: string
}
export const uploadFileToBucket = async ({
fileName,
file,
mimeType,
}: Props): Promise<string> => {
if (!env.S3_ENDPOINT || !env.S3_ACCESS_KEY || !env.S3_SECRET_KEY)
throw new Error(
'S3 not properly configured. Missing one of those variables: S3_ENDPOINT, S3_ACCESS_KEY, S3_SECRET_KEY'
)
const minioClient = new Client({
endPoint: env.S3_ENDPOINT,
port: env.S3_PORT,
useSSL: env.S3_SSL,
accessKey: env.S3_ACCESS_KEY,
secretKey: env.S3_SECRET_KEY,
region: env.S3_REGION,
})
await minioClient.putObject(env.S3_BUCKET, fileName, file, {
'Content-Type': mimeType,
})
return `http${env.S3_SSL ? 's' : ''}://${env.S3_ENDPOINT}${
env.S3_PORT ? `:${env.S3_PORT}` : ''
}/${env.S3_BUCKET}/${fileName}`
}

View File

@@ -1,9 +1,18 @@
import type { TypingEmulation } from '@typebot.io/schemas'
import {
TypingEmulation,
defaultSettings,
} from '@typebot.io/schemas/features/typebot/settings'
export const computeTypingDuration = (
bubbleContent: string,
typingSettings: TypingEmulation
) => {
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

View File

@@ -31,6 +31,7 @@ export const parseTestTypebot = (
isArchived: false,
isClosed: false,
resultsTablePreferences: null,
whatsAppPhoneNumberId: null,
variables: [{ id: 'var1', name: 'var1' }],
...partialTypebot,
edges: [

View File

@@ -248,8 +248,6 @@ export const uploadFiles = async ({
return urls
}
declare const window: any
export const hasValue = (
value: string | undefined | null
): value is NonNullable<string> =>

View File

@@ -198,6 +198,7 @@ model Typebot {
webhooks Webhook[]
isArchived Boolean @default(false)
isClosed Boolean @default(false)
whatsAppPhoneNumberId String?
@@index([workspaceId])
@@index([folderId])

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Typebot" ADD COLUMN "whatsAppPhoneNumberId" TEXT;

View File

@@ -182,6 +182,7 @@ model Typebot {
webhooks Webhook[]
isArchived Boolean @default(false)
isClosed Boolean @default(false)
whatsAppPhoneNumberId String?
@@index([workspaceId])
@@index([isArchived, createdAt(sort: Desc)])

View File

@@ -13,6 +13,8 @@ export const valueTypes = [
'Random ID',
'Moment of the day',
'Map item with same index',
'Phone number',
'Contact name',
] as const
export const hiddenTypes = ['Today']

View File

@@ -130,6 +130,12 @@ const startParamsSchema = z.object({
.describe(
'Set this to `true` if you intend to stream OpenAI completions on a client.'
),
isOnlyRegistering: z
.boolean()
.optional()
.describe(
'If set to `true`, it will only register the session and not start the chat. This is used for other chat platform integration as it can require a session to be registered before sending the first message.'
),
})
const replyLogSchema = logSchema

View File

@@ -2,6 +2,7 @@ import { z } from 'zod'
import { answerSchema } from '../answer'
import { resultSchema } from '../result'
import { typebotInSessionStateSchema, dynamicThemeSchema } from './shared'
import { settingsSchema } from '../typebot/settings'
const answerInSessionStateSchema = answerSchema.pick({
content: true,
@@ -64,6 +65,16 @@ const sessionStateSchemaV2 = z.object({
})
.optional(),
isStreamEnabled: z.boolean().optional(),
whatsApp: z
.object({
contact: z.object({
name: z.string(),
phoneNumber: z.string(),
}),
credentialsId: z.string().optional(),
})
.optional(),
typingEmulation: settingsSchema.shape.typingEmulation.optional(),
})
export type SessionState = z.infer<typeof sessionStateSchemaV2>

View File

@@ -3,12 +3,14 @@ import { stripeCredentialsSchema } from './blocks/inputs/payment/schemas'
import { googleSheetsCredentialsSchema } from './blocks/integrations/googleSheets/schemas'
import { openAICredentialsSchema } from './blocks/integrations/openai'
import { smtpCredentialsSchema } from './blocks/integrations/sendEmail'
import { whatsAppCredentialsSchema } from './whatsapp'
export const credentialsSchema = z.discriminatedUnion('type', [
smtpCredentialsSchema,
googleSheetsCredentialsSchema,
stripeCredentialsSchema,
openAICredentialsSchema,
whatsAppCredentialsSchema,
])
export type Credentials = z.infer<typeof credentialsSchema>

View File

@@ -1,13 +1,13 @@
import { PublicTypebot as PrismaPublicTypebot } from '@typebot.io/prisma'
import {
groupSchema,
edgeSchema,
variableSchema,
themeSchema,
settingsSchema,
} from './typebot'
import { z } from 'zod'
import { preprocessTypebot } from './typebot/helpers/preprocessTypebot'
import { edgeSchema } from './typebot/edge'
export const publicTypebotSchema = z.preprocess(
preprocessTypebot,

View File

@@ -0,0 +1,21 @@
import { z } from 'zod'
const sourceSchema = z.object({
groupId: z.string(),
blockId: z.string(),
itemId: z.string().optional(),
})
export type Source = z.infer<typeof sourceSchema>
const targetSchema = z.object({
groupId: z.string(),
blockId: z.string().optional(),
})
export type Target = z.infer<typeof targetSchema>
export const edgeSchema = z.object({
id: z.string(),
from: sourceSchema,
to: targetSchema,
})
export type Edge = z.infer<typeof edgeSchema>

View File

@@ -1,5 +1,6 @@
import { Block } from '../../blocks'
import { Group, edgeSchema } from '../typebot'
import { edgeSchema } from '../edge'
import type { Group } from '../typebot'
export const preprocessTypebot = (typebot: any) => {
if (!typebot || typebot.version === '5') return typebot

View File

@@ -2,3 +2,4 @@ export * from './typebot'
export * from './theme'
export * from './settings'
export * from './variable'
export * from './edge'

View File

@@ -1,4 +1,5 @@
import { z } from 'zod'
import { whatsAppSettingsSchema } from '../whatsapp'
export const rememberUserStorages = ['session', 'local'] as const
@@ -35,6 +36,7 @@ export const settingsSchema = z.object({
general: generalSettings,
typingEmulation: typingEmulation,
metadata: metadataSchema,
whatsApp: whatsAppSettingsSchema.optional(),
})
export const defaultSettings = ({

View File

@@ -5,6 +5,7 @@ import { variableSchema } from './variable'
import { Typebot as TypebotPrisma } from '@typebot.io/prisma'
import { blockSchema } from '../blocks/schemas'
import { preprocessTypebot } from './helpers/preprocessTypebot'
import { edgeSchema } from './edge'
export const groupSchema = z.object({
id: z.string(),
@@ -16,23 +17,6 @@ export const groupSchema = z.object({
blocks: z.array(blockSchema),
})
const sourceSchema = z.object({
groupId: z.string(),
blockId: z.string(),
itemId: z.string().optional(),
})
const targetSchema = z.object({
groupId: z.string(),
blockId: z.string().optional(),
})
export const edgeSchema = z.object({
id: z.string(),
from: sourceSchema,
to: targetSchema,
})
const resultsTablePreferencesSchema = z.object({
columnsOrder: z.array(z.string()),
columnsVisibility: z.record(z.string(), z.boolean()),
@@ -72,6 +56,7 @@ export const typebotSchema = z.preprocess(
resultsTablePreferences: resultsTablePreferencesSchema.nullable(),
isArchived: z.boolean(),
isClosed: z.boolean(),
whatsAppPhoneNumberId: z.string().nullable(),
}) satisfies z.ZodType<TypebotPrisma, z.ZodTypeDef, unknown>
)
@@ -93,9 +78,7 @@ export const typebotCreateSchema = typebotSchema._def.schema
.partial()
export type Typebot = z.infer<typeof typebotSchema>
export type Target = z.infer<typeof targetSchema>
export type Source = z.infer<typeof sourceSchema>
export type Edge = z.infer<typeof edgeSchema>
export type Group = z.infer<typeof groupSchema>
export type ResultsTablePreferences = z.infer<
typeof resultsTablePreferencesSchema

View File

@@ -0,0 +1,200 @@
import { z } from 'zod'
import { credentialsBaseSchema } from './blocks/baseSchemas'
import { ComparisonOperators, LogicalOperator } from './blocks/logic/condition'
const mediaSchema = z.object({ link: z.string() })
const headerSchema = z
.object({
type: z.literal('image'),
image: mediaSchema,
})
.or(
z.object({
type: z.literal('video'),
video: mediaSchema,
})
)
.or(
z.object({
type: z.literal('text'),
text: z.string(),
})
)
const bodySchema = z.object({
text: z.string(),
})
const actionSchema = z.object({
buttons: z.array(
z.object({
type: z.literal('reply'),
reply: z.object({ id: z.string(), title: z.string() }),
})
),
})
const templateSchema = z.object({
name: z.literal('preview_initial_message'),
language: z.object({
code: z.literal('en'),
}),
})
const interactiveSchema = z.object({
type: z.literal('button'),
header: headerSchema.optional(),
body: bodySchema.optional(),
action: actionSchema,
})
// https://developers.facebook.com/docs/whatsapp/cloud-api/reference/messages#message-object
const sendingMessageSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('text'),
text: z.object({
body: z.string(),
preview_url: z.boolean().optional(),
}),
preview_url: z.boolean().optional(),
}),
z.object({
type: z.literal('image'),
image: mediaSchema,
}),
z.object({
type: z.literal('audio'),
audio: mediaSchema,
}),
z.object({
type: z.literal('video'),
video: mediaSchema,
}),
z.object({
type: z.literal('interactive'),
interactive: interactiveSchema,
}),
z.object({
type: z.literal('template'),
template: templateSchema,
}),
])
export const incomingMessageSchema = z.discriminatedUnion('type', [
z.object({
from: z.string(),
type: z.literal('text'),
text: z.object({
body: z.string(),
}),
timestamp: z.string(),
}),
z.object({
from: z.string(),
type: z.literal('button'),
button: z.object({
text: z.string(),
payload: z.string(),
}),
timestamp: z.string(),
}),
z.object({
from: z.string(),
type: z.literal('interactive'),
interactive: z.object({
button_reply: z.object({
id: z.string(),
title: z.string(),
}),
}),
timestamp: z.string(),
}),
z.object({
from: z.string(),
type: z.literal('image'),
image: z.object({ id: z.string() }),
timestamp: z.string(),
}),
z.object({
from: z.string(),
type: z.literal('video'),
video: z.object({ id: z.string() }),
timestamp: z.string(),
}),
z.object({
from: z.string(),
type: z.literal('audio'),
audio: z.object({ id: z.string() }),
timestamp: z.string(),
}),
z.object({
from: z.string(),
type: z.literal('document'),
document: z.object({ id: z.string() }),
timestamp: z.string(),
}),
])
export const whatsAppWebhookRequestBodySchema = z.object({
entry: z.array(
z.object({
changes: z.array(
z.object({
value: z.object({
contacts: z
.array(
z.object({
profile: z.object({
name: z.string(),
}),
})
)
.optional(),
metadata: z.object({
display_phone_number: z.string(),
}),
messages: z.array(incomingMessageSchema).optional(),
}),
})
),
})
),
})
export const whatsAppCredentialsSchema = z
.object({
type: z.literal('whatsApp'),
data: z.object({
systemUserAccessToken: z.string(),
phoneNumberId: z.string(),
}),
})
.merge(credentialsBaseSchema)
const whatsAppComparisonSchema = z.object({
id: z.string(),
comparisonOperator: z.nativeEnum(ComparisonOperators).optional(),
value: z.string().optional(),
})
export type WhatsAppComparison = z.infer<typeof whatsAppComparisonSchema>
const startConditionSchema = z.object({
logicalOperator: z.nativeEnum(LogicalOperator),
comparisons: z.array(
z.object({
id: z.string(),
comparisonOperator: z.nativeEnum(ComparisonOperators).optional(),
value: z.string().optional(),
})
),
})
export const whatsAppSettingsSchema = z.object({
credentialsId: z.string().optional(),
startCondition: startConditionSchema.optional(),
})
export type WhatsAppIncomingMessage = z.infer<typeof incomingMessageSchema>
export type WhatsAppSendingMessage = z.infer<typeof sendingMessageSchema>
export type WhatsAppCredentials = z.infer<typeof whatsAppCredentialsSchema>