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

@ -99,6 +99,10 @@ Interested in self-hosting Typebot on your server? Take a look at the [self-host
You are awesome, lets build great software together. Head over to the [Contribute guidelines](https://github.com/baptisteArno/typebot.io/blob/main/CONTRIBUTING.md) to get started. 💪 You are awesome, lets build great software together. Head over to the [Contribute guidelines](https://github.com/baptisteArno/typebot.io/blob/main/CONTRIBUTING.md) to get started. 💪
## Run the project locally
Follow the [Get started](https://github.com/baptisteArno/typebot.io/blob/main/CONTRIBUTING.md#get-started) section of the Contributing guide.
### Top contributors ### Top contributors
<a href="https://github.com/baptistearno/typebot.io/graphs/contributors"> <a href="https://github.com/baptistearno/typebot.io/graphs/contributors">

View File

@ -40,6 +40,18 @@ const nextConfig = {
}, },
] ]
}, },
async rewrites() {
return process.env.NEXT_PUBLIC_POSTHOG_KEY
? [
{
source: '/ingest/:path*',
destination:
(process.env.NEXT_PUBLIC_POSTHOG_HOST ??
'https://app.posthog.com') + '/:path*',
},
]
: []
},
} }
const sentryWebpackPluginOptions = { const sentryWebpackPluginOptions = {

View File

@ -66,6 +66,7 @@
"got": "12.6.0", "got": "12.6.0",
"immer": "10.0.2", "immer": "10.0.2",
"jsonwebtoken": "9.0.1", "jsonwebtoken": "9.0.1",
"libphonenumber-js": "1.10.37",
"micro": "10.0.1", "micro": "10.0.1",
"micro-cors": "0.1.1", "micro-cors": "0.1.1",
"minio": "7.1.1", "minio": "7.1.1",
@ -76,6 +77,7 @@
"nodemailer": "6.9.3", "nodemailer": "6.9.3",
"nprogress": "0.2.0", "nprogress": "0.2.0",
"papaparse": "5.4.1", "papaparse": "5.4.1",
"posthog-js": "^1.77.1",
"posthog-node": "3.1.1", "posthog-node": "3.1.1",
"prettier": "2.8.8", "prettier": "2.8.8",
"qs": "6.11.2", "qs": "6.11.2",
@ -114,9 +116,9 @@
"@types/react": "18.2.15", "@types/react": "18.2.15",
"@types/tinycolor2": "1.4.3", "@types/tinycolor2": "1.4.3",
"dotenv-cli": "^7.2.1", "dotenv-cli": "^7.2.1",
"next-runtime-env": "^1.6.2",
"eslint": "8.44.0", "eslint": "8.44.0",
"eslint-config-custom": "workspace:*", "eslint-config-custom": "workspace:*",
"next-runtime-env": "^1.6.2",
"superjson": "^1.12.4", "superjson": "^1.12.4",
"typescript": "5.1.6", "typescript": "5.1.6",
"zod": "3.21.4" "zod": "3.21.4"

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 494 KiB

View File

@ -1,9 +1,9 @@
import { import {
Button, Button,
ButtonProps,
chakra, chakra,
Menu, Menu,
MenuButton, MenuButton,
MenuButtonProps,
MenuItem, MenuItem,
MenuList, MenuList,
Portal, Portal,
@ -27,7 +27,7 @@ export const DropdownList = <T extends readonly any[]>({
items, items,
placeholder = '', placeholder = '',
...props ...props
}: Props<T> & MenuButtonProps) => { }: Props<T> & ButtonProps) => {
const handleMenuItemClick = (operator: T[number]) => () => { const handleMenuItemClick = (operator: T[number]) => () => {
onItemSelect(operator) onItemSelect(operator)
} }

View File

@ -26,7 +26,7 @@ export const TextLink = ({
> >
<chakra.span textDecor="underline" display="inline-block" {...textProps}> <chakra.span textDecor="underline" display="inline-block" {...textProps}>
{isExternal ? ( {isExternal ? (
<HStack spacing={1}> <HStack as="span" spacing={1}>
<chakra.span noOfLines={noOfLines} maxW="100%"> <chakra.span noOfLines={noOfLines} maxW="100%">
{children} {children}
</chakra.span> </chakra.span>

View File

@ -1,4 +1,9 @@
import { import {
Accordion,
AccordionButton,
AccordionIcon,
AccordionItem,
AccordionPanel,
Flex, Flex,
HStack, HStack,
IconButton, IconButton,
@ -35,6 +40,7 @@ export const Toast = ({
onClose, onClose,
}: ToastProps) => { }: ToastProps) => {
const bgColor = useColorModeValue('white', 'gray.800') const bgColor = useColorModeValue('white', 'gray.800')
const detailsLabelColor = useColorModeValue('gray.600', 'gray.400')
return ( return (
<Flex <Flex
@ -56,14 +62,29 @@ export const Toast = ({
</Stack> </Stack>
{details && ( {details && (
<CodeEditor <Accordion allowToggle>
isReadOnly <AccordionItem>
value={details.content} <AccordionButton
lang={details.lang} justifyContent="space-between"
minWidth="300px" fontSize="sm"
maxHeight="200px" py="1"
maxWidth="calc(450px - 100px)" color={detailsLabelColor}
/> >
Details
<AccordionIcon />
</AccordionButton>
<AccordionPanel>
<CodeEditor
isReadOnly
value={details.content}
lang={details.lang}
minWidth="300px"
maxHeight="200px"
maxWidth="calc(450px - 100px)"
/>
</AccordionPanel>
</AccordionItem>
</Accordion>
)} )}
{(secondaryButton || primaryButton) && ( {(secondaryButton || primaryButton) && (
<HStack> <HStack>

View File

@ -1,5 +1,6 @@
import { import {
FormControl, FormControl,
FormControlProps,
FormLabel, FormLabel,
HStack, HStack,
Switch, Switch,
@ -13,13 +14,15 @@ export type SwitchWithLabelProps = {
initialValue: boolean initialValue: boolean
moreInfoContent?: string moreInfoContent?: string
onCheckChange: (isChecked: boolean) => void onCheckChange: (isChecked: boolean) => void
} & SwitchProps justifyContent?: FormControlProps['justifyContent']
} & Omit<SwitchProps, 'value' | 'justifyContent'>
export const SwitchWithLabel = ({ export const SwitchWithLabel = ({
label, label,
initialValue, initialValue,
moreInfoContent, moreInfoContent,
onCheckChange, onCheckChange,
justifyContent = 'space-between',
...switchProps ...switchProps
}: SwitchWithLabelProps) => { }: SwitchWithLabelProps) => {
const [isChecked, setIsChecked] = useState(initialValue) const [isChecked, setIsChecked] = useState(initialValue)
@ -28,8 +31,9 @@ export const SwitchWithLabel = ({
setIsChecked(!isChecked) setIsChecked(!isChecked)
onCheckChange(!isChecked) onCheckChange(!isChecked)
} }
return ( return (
<FormControl as={HStack} justifyContent="space-between"> <FormControl as={HStack} justifyContent={justifyContent}>
<FormLabel mb="0"> <FormLabel mb="0">
{label} {label}
{moreInfoContent && ( {moreInfoContent && (

View File

@ -35,7 +35,13 @@ export type TextInputProps = {
isDisabled?: boolean isDisabled?: boolean
} & Pick< } & Pick<
InputProps, InputProps,
'autoComplete' | 'onFocus' | 'onKeyUp' | 'type' | 'autoFocus' | 'size' | 'autoComplete'
| 'onFocus'
| 'onKeyUp'
| 'type'
| 'autoFocus'
| 'size'
| 'maxWidth'
> >
export const TextInput = forwardRef(function TextInput( export const TextInput = forwardRef(function TextInput(
@ -56,6 +62,7 @@ export const TextInput = forwardRef(function TextInput(
onFocus, onFocus,
onKeyUp, onKeyUp,
size, size,
maxWidth,
}: TextInputProps, }: TextInputProps,
ref ref
) { ) {
@ -122,6 +129,7 @@ export const TextInput = forwardRef(function TextInput(
onBlur={updateCarretPosition} onBlur={updateCarretPosition}
onChange={(e) => changeValue(e.target.value)} onChange={(e) => changeValue(e.target.value)}
size={size} size={size}
maxWidth={maxWidth}
/> />
) )

View File

@ -0,0 +1,60 @@
import { IconProps, Icon } from '@chakra-ui/react'
export const WhatsAppLogo = (props: IconProps) => (
<Icon viewBox="0 0 163 164" {...props}>
<g filter="url(#filter0_f_1006_58)">
<path
d="M48.5649 132.648L50.7999 133.972C60.1869 139.543 70.9499 142.49 81.9259 142.495H81.9489C115.656 142.495 143.088 115.069 143.102 81.3599C143.108 65.0249 136.753 49.6639 125.207 38.1089C119.544 32.4103 112.807 27.8915 105.386 24.8141C97.9646 21.7368 90.0068 20.162 81.9729 20.1809C48.2399 20.1809 20.8069 47.6039 20.7949 81.3109C20.7783 92.8208 24.0195 104.101 30.1439 113.846L31.5989 116.158L25.4199 138.716L48.5649 132.648ZM7.75391 156.192L18.1929 118.078C11.7549 106.924 8.36791 94.2699 8.37191 81.3059C8.38891 40.7499 41.3929 7.75586 81.9499 7.75586C101.631 7.76586 120.104 15.4249 133.997 29.3279C147.89 43.2309 155.534 61.7109 155.527 81.3649C155.509 121.918 122.5 154.918 81.9489 154.918H81.9169C69.6039 154.913 57.5049 151.824 46.7579 145.964L7.75391 156.192Z"
fill="#B3B3B3"
/>
</g>
<path
d="M7 155.436L17.439 117.322C10.9899 106.141 7.60242 93.4575 7.618 80.55C7.635 39.994 40.639 7 81.196 7C100.877 7.01 119.35 14.669 133.243 28.572C147.136 42.475 154.78 60.955 154.773 80.609C154.755 121.162 121.746 154.162 81.195 154.162H81.163C68.85 154.157 56.751 151.068 46.004 145.208L7 155.436Z"
fill="white"
/>
<path
d="M81.2171 19.425C47.4841 19.425 20.0511 46.848 20.0391 80.555C20.0225 92.065 23.2637 103.345 29.3881 113.09L30.8431 115.403L24.6641 137.961L47.8101 131.892L50.0451 133.216C59.4321 138.787 70.1951 141.733 81.1711 141.739H81.1941C114.901 141.739 142.334 114.313 142.347 80.604C142.373 72.5696 140.804 64.6099 137.732 57.1858C134.661 49.7617 130.147 43.0207 124.452 37.353C118.789 31.6543 112.052 27.1354 104.631 24.0581C97.2092 20.9807 89.2512 19.406 81.2171 19.425Z"
fill="url(#paint0_linear_1006_58)"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M62.8046 49.801C61.4266 46.74 59.9766 46.678 58.6676 46.625L55.1436 46.582C53.9176 46.582 51.9256 47.042 50.2416 48.882C48.5576 50.722 43.8066 55.169 43.8066 64.214C43.8066 73.259 50.3946 81.999 51.3126 83.227C52.2306 84.455 64.0306 103.608 82.7176 110.977C98.2466 117.101 101.407 115.883 104.779 115.577C108.151 115.271 115.656 111.13 117.187 106.837C118.718 102.544 118.719 98.866 118.26 98.097C117.801 97.328 116.575 96.871 114.735 95.951C112.895 95.031 103.858 90.584 102.173 89.97C100.488 89.356 99.2626 89.051 98.0356 90.891C96.8086 92.731 93.2896 96.87 92.2166 98.097C91.1436 99.324 90.0726 99.478 88.2326 98.559C86.3926 97.64 80.4726 95.698 73.4486 89.435C67.9836 84.562 64.2946 78.544 63.2206 76.705C62.1466 74.866 63.1066 73.87 64.0286 72.954C64.8536 72.13 65.8666 70.807 66.7876 69.734C67.7086 68.661 68.0116 67.894 68.6236 66.669C69.2356 65.444 68.9306 64.368 68.4706 63.449C68.0106 62.53 64.4386 53.437 62.8046 49.801Z"
fill="white"
/>
<defs>
<filter
id="filter0_f_1006_58"
x="0.691906"
y="0.69386"
width="161.897"
height="162.56"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend
mode="normal"
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/>
<feGaussianBlur
stdDeviation="3.531"
result="effect1_foregroundBlur_1006_58"
/>
</filter>
<linearGradient
id="paint0_linear_1006_58"
x1="79.9481"
y1="26.765"
x2="80.5681"
y2="131.29"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#57D163" />
<stop offset="1" stopColor="#23B33A" />
</linearGradient>
</defs>
</Icon>
)

View File

@ -8,6 +8,7 @@ import { useToast } from '@/hooks/useToast'
import { updateUserQuery } from './queries/updateUserQuery' import { updateUserQuery } from './queries/updateUserQuery'
import { useDebouncedCallback } from 'use-debounce' import { useDebouncedCallback } from 'use-debounce'
import { env } from '@typebot.io/env' import { env } from '@typebot.io/env'
import { identifyUser } from '../telemetry/posthog'
export const userContext = createContext<{ export const userContext = createContext<{
user?: User user?: User
@ -37,7 +38,11 @@ export const UserProvider = ({ children }: { children: ReactNode }) => {
) )
const parsedUser = session.user as User const parsedUser = session.user as User
setUser(parsedUser) setUser(parsedUser)
if (parsedUser?.id) setSentryUser({ id: parsedUser.id })
if (parsedUser?.id) {
setSentryUser({ id: parsedUser.id })
identifyUser(parsedUser.id)
}
}, [session, user]) }, [session, user])
useEffect(() => { useEffect(() => {

View File

@ -0,0 +1,32 @@
import {
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
} from '@chakra-ui/react'
import { ApiTokensList } from './ApiTokensList'
import { useUser } from '../hooks/useUser'
type Props = {
isOpen: boolean
onClose: () => void
}
export const ApiTokensModal = ({ isOpen, onClose }: Props) => {
const { user } = useUser()
if (!user) return
return (
<Modal isOpen={isOpen} onClose={onClose} size="xl">
<ModalOverlay />
<ModalContent>
<ModalHeader />
<ModalBody>
<ApiTokensList user={user} />
</ModalBody>
<ModalFooter />
</ModalContent>
</Modal>
)
}

View File

@ -15,7 +15,7 @@ import {
InputGroup, InputGroup,
InputRightElement, InputRightElement,
} from '@chakra-ui/react' } from '@chakra-ui/react'
import React, { FormEvent, useState } from 'react' import React, { FormEvent, useRef, useState } from 'react'
import { createApiTokenQuery } from '../queries/createApiTokenQuery' import { createApiTokenQuery } from '../queries/createApiTokenQuery'
import { ApiTokenFromServer } from '../types' import { ApiTokenFromServer } from '../types'
@ -32,6 +32,7 @@ export const CreateTokenModal = ({
onClose, onClose,
onNewToken, onNewToken,
}: Props) => { }: Props) => {
const inputRef = useRef<HTMLInputElement>(null)
const scopedT = useScopedI18n('account.apiTokens.createModal') const scopedT = useScopedI18n('account.apiTokens.createModal')
const [name, setName] = useState('') const [name, setName] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
@ -47,8 +48,9 @@ export const CreateTokenModal = ({
} }
setIsSubmitting(false) setIsSubmitting(false)
} }
return ( return (
<Modal isOpen={isOpen} onClose={onClose}> <Modal isOpen={isOpen} onClose={onClose} initialFocusRef={inputRef}>
<ModalOverlay /> <ModalOverlay />
<ModalContent> <ModalContent>
<ModalHeader> <ModalHeader>
@ -58,7 +60,7 @@ export const CreateTokenModal = ({
{newTokenValue ? ( {newTokenValue ? (
<ModalBody as={Stack} spacing="4"> <ModalBody as={Stack} spacing="4">
<Text> <Text>
{scopedT('copyInstruction')} {scopedT('copyInstruction')}{' '}
<strong>{scopedT('securityWarning')}</strong> <strong>{scopedT('securityWarning')}</strong>
</Text> </Text>
<InputGroup size="md"> <InputGroup size="md">
@ -72,6 +74,7 @@ export const CreateTokenModal = ({
<ModalBody as="form" onSubmit={createToken}> <ModalBody as="form" onSubmit={createToken}>
<Text mb="4">{scopedT('nameInput.label')}</Text> <Text mb="4">{scopedT('nameInput.label')}</Text>
<Input <Input
ref={inputRef}
placeholder={scopedT('nameInput.placeholder')} placeholder={scopedT('nameInput.placeholder')}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
/> />

View File

@ -120,6 +120,7 @@ export const PaymentSettings = ({ options, onOptionsChange }: Props) => {
currentCredentialsId={options.credentialsId} currentCredentialsId={options.credentialsId}
onCredentialsSelect={updateCredentials} onCredentialsSelect={updateCredentials}
onCreateNewClick={onOpen} onCreateNewClick={onOpen}
credentialsName="Stripe account"
/> />
)} )}
</Stack> </Stack>

View File

@ -21,8 +21,7 @@ test.describe('Payment input block', () => {
await page.goto(`/typebots/${typebotId}/edit`) await page.goto(`/typebots/${typebotId}/edit`)
await page.click('text=Configure...') await page.click('text=Configure...')
await page.getByRole('button', { name: 'Select an account' }).click() await page.getByRole('button', { name: 'Add Stripe account' }).click()
await page.click('text=Connect new')
await page.fill('[placeholder="Typebot"]', 'My Stripe Account') await page.fill('[placeholder="Typebot"]', 'My Stripe Account')
await page.fill('[placeholder="sk_test_..."]', env.STRIPE_SECRET_KEY ?? '') await page.fill('[placeholder="sk_test_..."]', env.STRIPE_SECRET_KEY ?? '')
await page.fill('[placeholder="sk_live_..."]', env.STRIPE_SECRET_KEY ?? '') await page.fill('[placeholder="sk_live_..."]', env.STRIPE_SECRET_KEY ?? '')

View File

@ -115,6 +115,7 @@ export const GoogleSheetsSettings = ({
currentCredentialsId={options?.credentialsId} currentCredentialsId={options?.credentialsId}
onCredentialsSelect={handleCredentialsIdChange} onCredentialsSelect={handleCredentialsIdChange}
onCreateNewClick={handleCreateNewClick} onCreateNewClick={handleCreateNewClick}
credentialsName="Sheets account"
/> />
)} )}
<GoogleSheetConnectModal <GoogleSheetConnectModal

View File

@ -149,7 +149,7 @@ test.describe.parallel('Google sheets integration', () => {
const fillInSpreadsheetInfo = async (page: Page) => { const fillInSpreadsheetInfo = async (page: Page) => {
await page.click('text=Configure...') await page.click('text=Configure...')
await page.click('text=Select an account') await page.click('text=Select Sheets account')
await page.click('text=pro-user@email.com') await page.click('text=pro-user@email.com')
await page.fill('input[placeholder="Search for spreadsheet"]', 'CR') await page.fill('input[placeholder="Search for spreadsheet"]', 'CR')

View File

@ -53,6 +53,7 @@ export const OpenAISettings = ({ options, onOptionsChange }: Props) => {
currentCredentialsId={options?.credentialsId} currentCredentialsId={options?.credentialsId}
onCredentialsSelect={updateCredentialsId} onCredentialsSelect={updateCredentialsId}
onCreateNewClick={onOpen} onCreateNewClick={onOpen}
credentialsName="OpenAI account"
/> />
)} )}
<OpenAICredentialsModal <OpenAICredentialsModal

View File

@ -18,8 +18,7 @@ test('should be configurable', async ({ page }) => {
]) ])
await page.goto(`/typebots/${typebotId}/edit`) await page.goto(`/typebots/${typebotId}/edit`)
await page.getByText('Configure...').click() await page.getByText('Configure...').click()
await page.getByRole('button', { name: 'Select an account' }).click() await page.getByRole('button', { name: 'Add OpenAI account' }).click()
await page.getByRole('menuitem', { name: 'Connect new' }).click()
await expect(page.getByRole('button', { name: 'Create' })).toBeDisabled() await expect(page.getByRole('button', { name: 'Create' })).toBeDisabled()
await page.getByPlaceholder('My account').fill('My account') await page.getByPlaceholder('My account').fill('My account')
await page.getByPlaceholder('sk-...').fill('sk-test') await page.getByPlaceholder('sk-...').fill('sk-test')

View File

@ -121,6 +121,7 @@ export const SendEmailSettings = ({ options, onOptionsChange }: Props) => {
defaultCredentialLabel={env.NEXT_PUBLIC_SMTP_FROM?.match( defaultCredentialLabel={env.NEXT_PUBLIC_SMTP_FROM?.match(
/<(.*)>/ /<(.*)>/
)?.pop()} )?.pop()}
credentialsName="SMTP account"
/> />
)} )}
</Stack> </Stack>

View File

@ -69,5 +69,13 @@ const Expression = ({
</Text> </Text>
) )
} }
case 'Contact name':
case 'Phone number':
return (
<Text as="span">
{variableName} ={' '}
<Tag colorScheme="purple">WhatsApp.{options.type}</Tag>
</Text>
)
} }
} }

View File

@ -10,6 +10,7 @@ import React from 'react'
import { VariableSearchInput } from '@/components/inputs/VariableSearchInput' import { VariableSearchInput } from '@/components/inputs/VariableSearchInput'
import { SwitchWithLabel } from '@/components/inputs/SwitchWithLabel' import { SwitchWithLabel } from '@/components/inputs/SwitchWithLabel'
import { Select } from '@/components/inputs/Select' import { Select } from '@/components/inputs/Select'
import { WhatsAppLogo } from '@/components/logos/WhatsAppLogo'
type Props = { type Props = {
options: SetVariableOptions options: SetVariableOptions
@ -47,7 +48,14 @@ export const SetVariableSettings = ({ options, onOptionsChange }: Props) => {
</Text> </Text>
<Select <Select
selectedItem={options.type ?? 'Custom'} selectedItem={options.type ?? 'Custom'}
items={setVarTypes} items={setVarTypes.map((type) => ({
label: type,
value: type,
icon:
type === 'Contact name' || type === 'Phone number' ? (
<WhatsAppLogo />
) : undefined,
}))}
onSelect={updateValueType} onSelect={updateValueType}
/> />
<SetVariableValue options={options} onOptionsChange={onOptionsChange} /> <SetVariableValue options={options} onOptionsChange={onOptionsChange} />
@ -150,6 +158,8 @@ const SetVariableValue = ({
</Alert> </Alert>
) )
} }
case 'Contact name':
case 'Phone number':
case 'Random ID': case 'Random ID':
case 'Now': case 'Now':
case 'Today': case 'Today':

View File

@ -8,6 +8,9 @@ import { smtpCredentialsSchema } from '@typebot.io/schemas/features/blocks/integ
import { encrypt } from '@typebot.io/lib/api/encryption' import { encrypt } from '@typebot.io/lib/api/encryption'
import { z } from 'zod' import { z } from 'zod'
import { isWriteWorkspaceForbidden } from '@/features/workspace/helpers/isWriteWorkspaceForbidden copy' import { isWriteWorkspaceForbidden } from '@/features/workspace/helpers/isWriteWorkspaceForbidden copy'
import { whatsAppCredentialsSchema } from '@typebot.io/schemas/features/whatsapp'
import { Credentials } from '@typebot.io/schemas'
import { isDefined } from '@typebot.io/lib/utils'
const inputShape = { const inputShape = {
data: true, data: true,
@ -33,6 +36,7 @@ export const createCredentials = authenticatedProcedure
smtpCredentialsSchema.pick(inputShape), smtpCredentialsSchema.pick(inputShape),
googleSheetsCredentialsSchema.pick(inputShape), googleSheetsCredentialsSchema.pick(inputShape),
openAICredentialsSchema.pick(inputShape), openAICredentialsSchema.pick(inputShape),
whatsAppCredentialsSchema.pick(inputShape),
]), ]),
}) })
) )
@ -42,6 +46,11 @@ export const createCredentials = authenticatedProcedure
}) })
) )
.mutation(async ({ input: { credentials }, ctx: { user } }) => { .mutation(async ({ input: { credentials }, ctx: { user } }) => {
if (await isNotAvailable(credentials.name, credentials.type))
throw new TRPCError({
code: 'CONFLICT',
message: 'Credentials already exist.',
})
const workspace = await prisma.workspace.findFirst({ const workspace = await prisma.workspace.findFirst({
where: { where: {
id: credentials.workspaceId, id: credentials.workspaceId,
@ -64,3 +73,14 @@ export const createCredentials = authenticatedProcedure
}) })
return { credentialsId: createdCredentials.id } return { credentialsId: createdCredentials.id }
}) })
const isNotAvailable = async (name: string, type: Credentials['type']) => {
if (type !== 'whatsApp') return
const existingCredentials = await prisma.credentials.findFirst({
where: {
type,
name,
},
})
return isDefined(existingCredentials)
}

View File

@ -30,6 +30,9 @@ export const deleteCredentials = authenticatedProcedure
const workspace = await prisma.workspace.findFirst({ const workspace = await prisma.workspace.findFirst({
where: { where: {
id: workspaceId, id: workspaceId,
members: {
some: { userId: user.id, role: { in: ['ADMIN', 'MEMBER'] } },
},
}, },
select: { id: true, members: true }, select: { id: true, members: true },
}) })

View File

@ -7,6 +7,7 @@ import { openAICredentialsSchema } from '@typebot.io/schemas/features/blocks/int
import { smtpCredentialsSchema } from '@typebot.io/schemas/features/blocks/integrations/sendEmail' import { smtpCredentialsSchema } from '@typebot.io/schemas/features/blocks/integrations/sendEmail'
import { z } from 'zod' import { z } from 'zod'
import { isReadWorkspaceFobidden } from '@/features/workspace/helpers/isReadWorkspaceFobidden' import { isReadWorkspaceFobidden } from '@/features/workspace/helpers/isReadWorkspaceFobidden'
import { whatsAppCredentialsSchema } from '@typebot.io/schemas/features/whatsapp'
export const listCredentials = authenticatedProcedure export const listCredentials = authenticatedProcedure
.meta({ .meta({
@ -24,7 +25,8 @@ export const listCredentials = authenticatedProcedure
type: stripeCredentialsSchema.shape.type type: stripeCredentialsSchema.shape.type
.or(smtpCredentialsSchema.shape.type) .or(smtpCredentialsSchema.shape.type)
.or(googleSheetsCredentialsSchema.shape.type) .or(googleSheetsCredentialsSchema.shape.type)
.or(openAICredentialsSchema.shape.type), .or(openAICredentialsSchema.shape.type)
.or(whatsAppCredentialsSchema.shape.type),
}) })
) )
.output( .output(

View File

@ -1,9 +1,9 @@
import { import {
Button, Button,
ButtonProps,
IconButton, IconButton,
Menu, Menu,
MenuButton, MenuButton,
MenuButtonProps,
MenuItem, MenuItem,
MenuList, MenuList,
Stack, Stack,
@ -16,13 +16,14 @@ import { useToast } from '../../../hooks/useToast'
import { Credentials } from '@typebot.io/schemas' import { Credentials } from '@typebot.io/schemas'
import { trpc } from '@/lib/trpc' import { trpc } from '@/lib/trpc'
type Props = Omit<MenuButtonProps, 'type'> & { type Props = Omit<ButtonProps, 'type'> & {
type: Credentials['type'] type: Credentials['type']
workspaceId: string workspaceId: string
currentCredentialsId?: string currentCredentialsId?: string
onCredentialsSelect: (credentialId?: string) => void onCredentialsSelect: (credentialId?: string) => void
onCreateNewClick: () => void onCreateNewClick: () => void
defaultCredentialLabel?: string defaultCredentialLabel?: string
credentialsName: string
} }
export const CredentialsDropdown = ({ export const CredentialsDropdown = ({
@ -32,6 +33,7 @@ export const CredentialsDropdown = ({
onCredentialsSelect, onCredentialsSelect,
onCreateNewClick, onCreateNewClick,
defaultCredentialLabel, defaultCredentialLabel,
credentialsName,
...props ...props
}: Props) => { }: Props) => {
const router = useRouter() const router = useRouter()
@ -59,7 +61,8 @@ export const CredentialsDropdown = ({
}, },
}) })
const defaultCredentialsLabel = defaultCredentialLabel ?? `Select an account` const defaultCredentialsLabel =
defaultCredentialLabel ?? `Select ${credentialsName}`
const currentCredential = data?.credentials.find( const currentCredential = data?.credentials.find(
(c) => c.id === currentCredentialsId (c) => c.id === currentCredentialsId
@ -97,6 +100,19 @@ export const CredentialsDropdown = ({
mutate({ workspaceId, credentialsId }) mutate({ workspaceId, credentialsId })
} }
if (data?.credentials.length === 0 && !defaultCredentialLabel) {
return (
<Button
colorScheme="gray"
textAlign="left"
leftIcon={<PlusIcon />}
onClick={onCreateNewClick}
{...props}
>
Add {credentialsName}
</Button>
)
}
return ( return (
<Menu isLazy> <Menu isLazy>
<MenuButton <MenuButton
@ -107,7 +123,11 @@ export const CredentialsDropdown = ({
textAlign="left" textAlign="left"
{...props} {...props}
> >
<Text noOfLines={1} overflowY="visible" h="20px"> <Text
noOfLines={1}
overflowY="visible"
h={props.size === 'sm' ? '18px' : '20px'}
>
{currentCredential ? currentCredential.name : defaultCredentialsLabel} {currentCredential ? currentCredential.name : defaultCredentialsLabel}
</Text> </Text>
</MenuButton> </MenuButton>

View File

@ -5,6 +5,7 @@ import { z } from 'zod'
import { customDomainSchema } from '@typebot.io/schemas/features/customDomains' import { customDomainSchema } from '@typebot.io/schemas/features/customDomains'
import { isWriteWorkspaceForbidden } from '@/features/workspace/helpers/isWriteWorkspaceForbidden copy' import { isWriteWorkspaceForbidden } from '@/features/workspace/helpers/isWriteWorkspaceForbidden copy'
import got, { HTTPError } from 'got' import got, { HTTPError } from 'got'
import { env } from '@typebot.io/env'
export const createCustomDomain = authenticatedProcedure export const createCustomDomain = authenticatedProcedure
.meta({ .meta({
@ -79,7 +80,7 @@ export const createCustomDomain = authenticatedProcedure
const createDomainOnVercel = (name: string) => const createDomainOnVercel = (name: string) =>
got.post({ got.post({
url: `https://api.vercel.com/v10/projects/${process.env.NEXT_PUBLIC_VERCEL_VIEWER_PROJECT_NAME}/domains?teamId=${process.env.VERCEL_TEAM_ID}`, url: `https://api.vercel.com/v10/projects/${env.NEXT_PUBLIC_VERCEL_VIEWER_PROJECT_NAME}/domains?teamId=${env.VERCEL_TEAM_ID}`,
headers: { Authorization: `Bearer ${process.env.VERCEL_TOKEN}` }, headers: { Authorization: `Bearer ${env.VERCEL_TOKEN}` },
json: { name }, json: { name },
}) })

View File

@ -4,6 +4,7 @@ import { TRPCError } from '@trpc/server'
import { z } from 'zod' import { z } from 'zod'
import { isWriteWorkspaceForbidden } from '@/features/workspace/helpers/isWriteWorkspaceForbidden copy' import { isWriteWorkspaceForbidden } from '@/features/workspace/helpers/isWriteWorkspaceForbidden copy'
import got from 'got' import got from 'got'
import { env } from '@typebot.io/env'
export const deleteCustomDomain = authenticatedProcedure export const deleteCustomDomain = authenticatedProcedure
.meta({ .meta({
@ -63,6 +64,6 @@ export const deleteCustomDomain = authenticatedProcedure
const deleteDomainOnVercel = (name: string) => const deleteDomainOnVercel = (name: string) =>
got.delete({ got.delete({
url: `https://api.vercel.com/v9/projects/${process.env.NEXT_PUBLIC_VERCEL_VIEWER_PROJECT_NAME}/domains/${name}?teamId=${process.env.VERCEL_TEAM_ID}`, url: `https://api.vercel.com/v9/projects/${env.NEXT_PUBLIC_VERCEL_VIEWER_PROJECT_NAME}/domains/${name}?teamId=${env.VERCEL_TEAM_ID}`,
headers: { Authorization: `Bearer ${process.env.VERCEL_TOKEN}` }, headers: { Authorization: `Bearer ${env.VERCEL_TOKEN}` },
}) })

View File

@ -73,7 +73,7 @@ export const TypebotHeader = () => {
}) })
const handleHelpClick = () => { const handleHelpClick = () => {
isCloudProdInstance isCloudProdInstance()
? onOpen() ? onOpen()
: window.open('https://docs.typebot.io', '_blank') : window.open('https://docs.typebot.io', '_blank')
} }

View File

@ -38,6 +38,7 @@ type UpdateTypebotPayload = Partial<
| 'customDomain' | 'customDomain'
| 'resultsTablePreferences' | 'resultsTablePreferences'
| 'isClosed' | 'isClosed'
| 'whatsAppPhoneNumberId'
> >
> >

View File

@ -0,0 +1,45 @@
import { authenticatedProcedure } from '@/helpers/server/trpc'
import { z } from 'zod'
import got, { HTTPError } from 'got'
import { getViewerUrl } from '@typebot.io/lib'
import prisma from '@/lib/prisma'
import { TRPCError } from '@trpc/server'
export const sendWhatsAppInitialMessage = authenticatedProcedure
.input(
z.object({
to: z.string(),
typebotId: z.string(),
startGroupId: z.string().optional(),
})
)
.mutation(
async ({ input: { to, typebotId, startGroupId }, ctx: { user } }) => {
const apiToken = await prisma.apiToken.findFirst({
where: { ownerId: user.id },
})
if (!apiToken)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Api Token not found',
})
try {
await got.post({
method: 'POST',
url: `${getViewerUrl()}/api/v1/typebots/${typebotId}/whatsapp/start-preview`,
headers: {
Authorization: `Bearer ${apiToken.token}`,
},
json: { to, isPreview: true, startGroupId },
})
} catch (error) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Request to viewer failed',
cause: error instanceof HTTPError ? error.response.body : error,
})
}
return { message: 'success' }
}
)

View File

@ -44,7 +44,7 @@ export const ApiPreviewInstructions = (props: StackProps) => {
w="full" w="full"
{...props} {...props}
> >
<OrderedList spacing={6}> <OrderedList spacing={6} px="1">
<ListItem> <ListItem>
All your requests need to be authenticated with an API token.{' '} All your requests need to be authenticated with an API token.{' '}
<TextLink href="https://docs.typebot.io/api/builder/authenticate"> <TextLink href="https://docs.typebot.io/api/builder/authenticate">
@ -93,7 +93,7 @@ export const ApiPreviewInstructions = (props: StackProps) => {
</Stack> </Stack>
</ListItem> </ListItem>
</OrderedList> </OrderedList>
<Text fontSize="sm"> <Text fontSize="sm" pl="1">
Check out the{' '} Check out the{' '}
<TextLink href="https://docs.typebot.io/api/send-a-message" isExternal> <TextLink href="https://docs.typebot.io/api/send-a-message" isExternal>
API reference API reference

View File

@ -18,8 +18,18 @@ import { PreviewDrawerBody } from './PreviewDrawerBody'
import { useDrag } from '@use-gesture/react' import { useDrag } from '@use-gesture/react'
import { ResizeHandle } from './ResizeHandle' import { ResizeHandle } from './ResizeHandle'
const preferredRuntimeKey = 'preferredRuntime'
const getDefaultRuntime = (typebotId?: string) => {
if (!typebotId) return runtimes[0]
const preferredRuntime = localStorage.getItem(preferredRuntimeKey)
return (
runtimes.find((runtime) => runtime.name === preferredRuntime) ?? runtimes[0]
)
}
export const PreviewDrawer = () => { export const PreviewDrawer = () => {
const { save, isSavingLoading } = useTypebot() const { typebot, save, isSavingLoading } = useTypebot()
const { setRightPanel } = useEditor() const { setRightPanel } = useEditor()
const { setPreviewingBlock } = useGraph() const { setPreviewingBlock } = useGraph()
const [width, setWidth] = useState(500) const [width, setWidth] = useState(500)
@ -27,7 +37,7 @@ export const PreviewDrawer = () => {
const [restartKey, setRestartKey] = useState(0) const [restartKey, setRestartKey] = useState(0)
const [selectedRuntime, setSelectedRuntime] = useState< const [selectedRuntime, setSelectedRuntime] = useState<
(typeof runtimes)[number] (typeof runtimes)[number]
>(runtimes[0]) >(getDefaultRuntime(typebot?.id))
const handleRestartClick = async () => { const handleRestartClick = async () => {
await save() await save()
@ -48,6 +58,13 @@ export const PreviewDrawer = () => {
} }
) )
const setPreviewRuntimeAndSaveIntoLocalStorage = (
runtime: (typeof runtimes)[number]
) => {
setSelectedRuntime(runtime)
localStorage.setItem(preferredRuntimeKey, runtime.name)
}
return ( return (
<Flex <Flex
pos="absolute" pos="absolute"
@ -78,7 +95,7 @@ export const PreviewDrawer = () => {
<HStack> <HStack>
<RuntimeMenu <RuntimeMenu
selectedRuntime={selectedRuntime} selectedRuntime={selectedRuntime}
onSelectRuntime={(runtime) => setSelectedRuntime(runtime)} onSelectRuntime={setPreviewRuntimeAndSaveIntoLocalStorage}
/> />
{selectedRuntime.name === 'Web' ? ( {selectedRuntime.name === 'Web' ? (
<Button <Button

View File

@ -1,16 +1,20 @@
import { runtimes } from '../data' import { runtimes } from '../data'
import { ApiPreviewInstructions } from './ApiPreviewInstructions' import { ApiPreviewInstructions } from './ApiPreviewInstructions'
import { WebPreview } from './WebPreview' import { WebPreview } from './WebPreview'
import { WhatsAppPreviewInstructions } from './WhatsAppPreviewInstructions'
type Props = { type Props = {
runtime: (typeof runtimes)[number]['name'] runtime: (typeof runtimes)[number]['name']
} }
export const PreviewDrawerBody = ({ runtime }: Props) => { export const PreviewDrawerBody = ({ runtime }: Props): JSX.Element => {
switch (runtime) { switch (runtime) {
case 'Web': { case 'Web': {
return <WebPreview /> return <WebPreview />
} }
case 'WhatsApp': {
return <WhatsAppPreviewInstructions />
}
case 'API': { case 'API': {
return <ApiPreviewInstructions pt="4" /> return <ApiPreviewInstructions pt="4" />
} }

View File

@ -10,6 +10,7 @@ import {
Text, Text,
} from '@chakra-ui/react' } from '@chakra-ui/react'
import { runtimes } from '../data' import { runtimes } from '../data'
import { getFeatureFlags } from '@/features/telemetry/posthog'
type Runtime = (typeof runtimes)[number] type Runtime = (typeof runtimes)[number]
@ -18,37 +19,44 @@ type Props = {
onSelectRuntime: (runtime: Runtime) => void onSelectRuntime: (runtime: Runtime) => void
} }
export const RuntimeMenu = ({ selectedRuntime, onSelectRuntime }: Props) => ( export const RuntimeMenu = ({ selectedRuntime, onSelectRuntime }: Props) => {
<Menu> return (
<MenuButton <Menu>
as={Button} <MenuButton
leftIcon={selectedRuntime.icon} as={Button}
rightIcon={<ChevronDownIcon />} leftIcon={selectedRuntime.icon}
> rightIcon={<ChevronDownIcon />}
<HStack justifyContent="space-between"> >
<Text>{selectedRuntime.name}</Text> <HStack justifyContent="space-between">
{'status' in selectedRuntime ? ( <Text>{selectedRuntime.name}</Text>
<Tag colorScheme="orange">{selectedRuntime.status}</Tag> {'status' in selectedRuntime ? (
) : null} <Tag colorScheme="orange">{selectedRuntime.status}</Tag>
</HStack> ) : null}
</MenuButton> </HStack>
<MenuList w="100px"> </MenuButton>
{runtimes <MenuList w="100px">
.filter((runtime) => runtime.name !== selectedRuntime.name) {runtimes
.map((runtime) => ( .filter((runtime) => runtime.name !== selectedRuntime.name)
<MenuItem .filter((runtime) =>
key={runtime.name} runtime.name === 'WhatsApp'
icon={runtime.icon} ? getFeatureFlags().includes('whatsApp')
onClick={() => onSelectRuntime(runtime)} : true
> )
<HStack justifyContent="space-between"> .map((runtime) => (
<Text>{runtime.name}</Text> <MenuItem
{'status' in runtime ? ( key={runtime.name}
<Tag colorScheme="orange">{runtime.status}</Tag> icon={runtime.icon}
) : null} onClick={() => onSelectRuntime(runtime)}
</HStack> >
</MenuItem> <HStack justifyContent="space-between">
))} <Text>{runtime.name}</Text>
</MenuList> {'status' in runtime ? (
</Menu> <Tag colorScheme="orange">{runtime.status}</Tag>
) ) : null}
</HStack>
</MenuItem>
))}
</MenuList>
</Menu>
)
}

View File

@ -0,0 +1,112 @@
import { TextInput } from '@/components/inputs'
import { useTypebot } from '@/features/editor/providers/TypebotProvider'
import { useToast } from '@/hooks/useToast'
import { trpc } from '@/lib/trpc'
import {
Alert,
AlertIcon,
Button,
Flex,
HStack,
SlideFade,
Stack,
StackProps,
Text,
} from '@chakra-ui/react'
import { isEmpty } from '@typebot.io/lib'
import { FormEvent, useState } from 'react'
import {
getPhoneNumberFromLocalStorage,
setPhoneNumberInLocalStorage,
} from '../helpers/phoneNumberFromLocalStorage'
import { useEditor } from '@/features/editor/providers/EditorProvider'
export const WhatsAppPreviewInstructions = (props: StackProps) => {
const { typebot, save } = useTypebot()
const { startPreviewAtGroup } = useEditor()
const [phoneNumber, setPhoneNumber] = useState(
getPhoneNumberFromLocalStorage() ?? ''
)
const [isSendingMessage, setIsSendingMessage] = useState(false)
const [isMessageSent, setIsMessageSent] = useState(false)
const [hasMessageBeenSent, setHasMessageBeenSent] = useState(false)
const { showToast } = useToast()
const { mutate } = trpc.sendWhatsAppInitialMessage.useMutation({
onMutate: () => setIsSendingMessage(true),
onSettled: () => setIsSendingMessage(false),
onError: (error) => showToast({ description: error.message }),
onSuccess: async (data) => {
if (
data?.message === 'success' &&
phoneNumber !== getPhoneNumberFromLocalStorage()
)
setPhoneNumberInLocalStorage(phoneNumber)
setHasMessageBeenSent(true)
setIsMessageSent(true)
setTimeout(() => setIsMessageSent(false), 30000)
},
})
const sendWhatsAppPreviewStartMessage = async (e: FormEvent) => {
e.preventDefault()
if (!typebot) return
await save()
mutate({
to: phoneNumber,
typebotId: typebot.id,
startGroupId: startPreviewAtGroup,
})
}
return (
<Stack
as="form"
spacing={4}
overflowY="scroll"
className="hide-scrollbar"
w="full"
px="1"
onSubmit={sendWhatsAppPreviewStartMessage}
{...props}
>
<Alert status="warning">
<AlertIcon />
The WhatsApp integration is still experimental.
<br />I appreciate your bug reports 🧡
</Alert>
<TextInput
label="Your phone number"
placeholder="+XXXXXXXXXXXX"
type="tel"
withVariableButton={false}
debounceTimeout={0}
defaultValue={phoneNumber}
onChange={setPhoneNumber}
/>
<Button
isDisabled={isEmpty(phoneNumber) || isMessageSent}
isLoading={isSendingMessage}
type="submit"
>
{hasMessageBeenSent ? 'Restart' : 'Start'} the chat
</Button>
<SlideFade offsetY="20px" in={isMessageSent} unmountOnExit>
<Flex>
<Alert status="success" w="100%">
<HStack>
<AlertIcon />
<Stack spacing={1}>
<Text fontWeight="semibold">Chat started!</Text>
<Text fontSize="sm">
Open WhatsApp to test your bot. The first message can take up
to 2 min to be delivered.
</Text>
</Stack>
</HStack>
</Alert>
</Flex>
</SlideFade>
</Stack>
)
}

View File

@ -1,9 +1,15 @@
import { GlobeIcon, CodeIcon } from '@/components/icons' import { GlobeIcon, CodeIcon } from '@/components/icons'
import { WhatsAppLogo } from '@/components/logos/WhatsAppLogo'
export const runtimes = [ export const runtimes = [
{ {
name: 'Web', name: 'Web',
icon: <GlobeIcon />, icon: <GlobeIcon />,
}, },
{ name: 'API', icon: <CodeIcon />, status: 'beta' }, {
name: 'WhatsApp',
icon: <WhatsAppLogo />,
status: 'beta',
},
{ name: 'API', icon: <CodeIcon /> },
] as const ] as const

View File

@ -0,0 +1,8 @@
export const phoneNumberKey = 'whatsapp-phone'
export const getPhoneNumberFromLocalStorage = () =>
localStorage.getItem(phoneNumberKey)
export const setPhoneNumberInLocalStorage = (phoneNumber: string) => {
localStorage.setItem(phoneNumberKey, phoneNumber)
}

View File

@ -32,11 +32,17 @@ import { trpc } from '@/lib/trpc'
import { useToast } from '@/hooks/useToast' import { useToast } from '@/hooks/useToast'
import { parseDefaultPublicId } from '../helpers/parseDefaultPublicId' import { parseDefaultPublicId } from '../helpers/parseDefaultPublicId'
export const PublishButton = (props: ButtonProps) => { type Props = ButtonProps & {
isMoreMenuDisabled?: boolean
}
export const PublishButton = ({
isMoreMenuDisabled = false,
...props
}: Props) => {
const t = useI18n() const t = useI18n()
const warningTextColor = useColorModeValue('red.300', 'red.600') const warningTextColor = useColorModeValue('red.300', 'red.600')
const { workspace } = useWorkspace() const { workspace } = useWorkspace()
const { push, query } = useRouter() const { push, query, pathname } = useRouter()
const { isOpen, onOpen, onClose } = useDisclosure() const { isOpen, onOpen, onClose } = useDisclosure()
const { const {
isPublished, isPublished,
@ -66,7 +72,8 @@ export const PublishButton = (props: ButtonProps) => {
refetchPublishedTypebot({ refetchPublishedTypebot({
typebotId: typebot?.id as string, typebotId: typebot?.id as string,
}) })
if (!publishedTypebot) push(`/typebots/${query.typebotId}/share`) if (!publishedTypebot && !pathname.endsWith('share'))
push(`/typebots/${query.typebotId}/share`)
}, },
}) })
@ -153,7 +160,9 @@ export const PublishButton = (props: ButtonProps) => {
isLoading={isPublishing || isUnpublishing} isLoading={isPublishing || isUnpublishing}
isDisabled={isPublished || isSavingLoading} isDisabled={isPublished || isSavingLoading}
onClick={handlePublishClick} onClick={handlePublishClick}
borderRightRadius={publishedTypebot ? 0 : undefined} borderRightRadius={
publishedTypebot && !isMoreMenuDisabled ? 0 : undefined
}
{...props} {...props}
> >
{isPublished {isPublished
@ -164,7 +173,7 @@ export const PublishButton = (props: ButtonProps) => {
</Button> </Button>
</Tooltip> </Tooltip>
{publishedTypebot && ( {!isMoreMenuDisabled && publishedTypebot && (
<Menu> <Menu>
<MenuButton <MenuButton
as={IconButton} as={IconButton}

View File

@ -68,7 +68,7 @@ export const SharePage = () => {
const checkIfPublicIdIsValid = async (publicId: string) => { const checkIfPublicIdIsValid = async (publicId: string) => {
const isLongerThanAllowed = publicId.length >= 4 const isLongerThanAllowed = publicId.length >= 4
if (!isLongerThanAllowed && isCloudProdInstance) { if (!isLongerThanAllowed && isCloudProdInstance()) {
showToast({ showToast({
description: 'Should be longer than 4 characters', description: 'Should be longer than 4 characters',
}) })

View File

@ -39,6 +39,10 @@ import { FlutterFlowLogo } from './logos/FlutterFlowLogo'
import { FlutterFlowModal } from './modals/FlutterFlowModal' import { FlutterFlowModal } from './modals/FlutterFlowModal'
import { NextjsLogo } from './logos/NextjsLogo' import { NextjsLogo } from './logos/NextjsLogo'
import { NextjsModal } from './modals/Nextjs/NextjsModal' import { NextjsModal } from './modals/Nextjs/NextjsModal'
import { WhatsAppLogo } from '@/components/logos/WhatsAppLogo'
import { WhatsAppModal } from './modals/WhatsAppModal/WhatsAppModal'
import { ParentModalProvider } from '@/features/graph/providers/ParentModalProvider'
import { getFeatureFlags } from '@/features/telemetry/posthog'
export type ModalProps = { export type ModalProps = {
publicId: string publicId: string
@ -79,6 +83,19 @@ export const EmbedButton = ({
} }
export const integrationsList = [ export const integrationsList = [
(props: Pick<ModalProps, 'publicId' | 'isPublished'>) => {
if (getFeatureFlags().includes('whatsApp'))
return (
<ParentModalProvider>
<EmbedButton
logo={<WhatsAppLogo height={100} width="70px" />}
label="WhatsApp"
Modal={WhatsAppModal}
{...props}
/>
</ParentModalProvider>
)
},
(props: Pick<ModalProps, 'publicId' | 'isPublished'>) => ( (props: Pick<ModalProps, 'publicId' | 'isPublished'>) => (
<EmbedButton <EmbedButton
logo={<WordpressLogo height={100} width="70px" />} logo={<WordpressLogo height={100} width="70px" />}

View File

@ -0,0 +1,70 @@
import { HStack, Text } from '@chakra-ui/react'
import { DropdownList } from '@/components/DropdownList'
import { ComparisonOperators } from '@typebot.io/schemas'
import { TableListItemProps } from '@/components/TableList'
import { TextInput } from '@/components/inputs'
import { WhatsAppComparison } from '@typebot.io/schemas/features/whatsapp'
export const WhatsAppComparisonItem = ({
item,
onItemChange,
}: TableListItemProps<WhatsAppComparison>) => {
const handleSelectComparisonOperator = (
comparisonOperator: ComparisonOperators
) => {
if (comparisonOperator === item.comparisonOperator) return
onItemChange({ ...item, comparisonOperator })
}
const handleChangeValue = (value: string) => {
if (value === item.value) return
onItemChange({ ...item, value })
}
return (
<HStack p="4" rounded="md" flex="1" borderWidth="1px">
<Text flexShrink={0}>User message</Text>
<DropdownList
currentItem={item.comparisonOperator}
onItemSelect={handleSelectComparisonOperator}
items={Object.values(ComparisonOperators)}
placeholder="Select an operator"
size="sm"
flexShrink={0}
/>
{item.comparisonOperator !== ComparisonOperators.IS_SET &&
item.comparisonOperator !== ComparisonOperators.IS_EMPTY && (
<TextInput
defaultValue={item.value ?? ''}
onChange={handleChangeValue}
placeholder={parseValuePlaceholder(item.comparisonOperator)}
withVariableButton={false}
size="sm"
/>
)}
</HStack>
)
}
const parseValuePlaceholder = (
operator: ComparisonOperators | undefined
): string => {
switch (operator) {
case ComparisonOperators.NOT_EQUAL:
case ComparisonOperators.EQUAL:
case ComparisonOperators.CONTAINS:
case ComparisonOperators.STARTS_WITH:
case ComparisonOperators.ENDS_WITH:
case ComparisonOperators.NOT_CONTAINS:
case undefined:
return 'Type a value...'
case ComparisonOperators.LESS:
case ComparisonOperators.GREATER:
return 'Type a number...'
case ComparisonOperators.IS_SET:
case ComparisonOperators.IS_EMPTY:
return ''
case ComparisonOperators.MATCHES_REGEX:
case ComparisonOperators.NOT_MATCH_REGEX:
return '^[0-9]+$'
}
}

View File

@ -0,0 +1,502 @@
import { CopyButton } from '@/components/CopyButton'
import { TextLink } from '@/components/TextLink'
import { ChevronLeftIcon, ExternalLinkIcon } from '@/components/icons'
import { TextInput } from '@/components/inputs/TextInput'
import { useWorkspace } from '@/features/workspace/WorkspaceProvider'
import { useToast } from '@/hooks/useToast'
import { trpc, trpcVanilla } from '@/lib/trpc'
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalCloseButton,
ModalBody,
Stack,
ModalFooter,
Stepper,
useSteps,
Step,
StepIndicator,
Box,
StepIcon,
StepNumber,
StepSeparator,
StepStatus,
StepTitle,
UnorderedList,
ListItem,
Text,
Image,
Button,
HStack,
IconButton,
Heading,
OrderedList,
Link,
Code,
Input,
InputGroup,
InputRightElement,
} from '@chakra-ui/react'
import { env } from '@typebot.io/env'
import { getViewerUrl, isEmpty, isNotEmpty } from '@typebot.io/lib/utils'
import React, { useState } from 'react'
const steps = [
{ title: 'Requirements' },
{ title: 'User Token' },
{ title: 'Phone Number' },
{ title: 'Webhook' },
]
type Props = {
isOpen: boolean
onClose: () => void
onNewCredentials: (id: string) => void
}
export const WhatsAppCredentialsModal = ({
isOpen,
onClose,
onNewCredentials,
}: Props) => {
const { workspace } = useWorkspace()
const { showToast } = useToast()
const { activeStep, goToNext, goToPrevious, setActiveStep } = useSteps({
index: 0,
count: steps.length,
})
const [systemUserAccessToken, setSystemUserAccessToken] = useState('')
const [phoneNumberId, setPhoneNumberId] = useState('')
const [phoneNumberName, setPhoneNumberName] = useState('')
const [verificationToken, setVerificationToken] = useState('')
const [isVerifying, setIsVerifying] = useState(false)
const [isCreating, setIsCreating] = useState(false)
const {
credentials: {
listCredentials: { refetch: refetchCredentials },
},
} = trpc.useContext()
const { mutate } = trpc.credentials.createCredentials.useMutation({
onMutate: () => setIsCreating(true),
onSettled: () => setIsCreating(false),
onError: (err) => {
showToast({
description: err.message,
status: 'error',
})
},
onSuccess: (data) => {
refetchCredentials()
onNewCredentials(data.credentialsId)
onClose()
resetForm()
},
})
const { data: tokenInfoData } = trpc.whatsApp.getSystemTokenInfo.useQuery(
{
token: systemUserAccessToken,
},
{ enabled: isNotEmpty(systemUserAccessToken) }
)
const resetForm = () => {
setActiveStep(0)
setSystemUserAccessToken('')
setPhoneNumberId('')
}
const createMetaCredentials = async () => {
if (!workspace) return
mutate({
credentials: {
type: 'whatsApp',
workspaceId: workspace.id,
name: phoneNumberName,
data: {
systemUserAccessToken,
phoneNumberId,
},
},
})
}
const isTokenValid = async () => {
setIsVerifying(true)
try {
const { expiresAt, scopes } =
await trpcVanilla.whatsApp.getSystemTokenInfo.query({
token: systemUserAccessToken,
})
if (expiresAt !== 0) {
showToast({
description:
'Token expiration was not set to *never*. Create the token again with the correct expiration.',
})
return false
}
if (
['whatsapp_business_management', 'whatsapp_business_messaging'].find(
(scope) => !scopes.includes(scope)
)
) {
showToast({
description: 'Token does not have all the necessary scopes',
})
return false
}
} catch (err) {
setIsVerifying(false)
showToast({
description: 'Could not get system info',
})
return false
}
setIsVerifying(false)
return true
}
const isPhoneNumberAvailable = async () => {
setIsVerifying(true)
try {
const { name } = await trpcVanilla.whatsApp.getPhoneNumber.query({
systemToken: systemUserAccessToken,
phoneNumberId,
})
setPhoneNumberName(name)
try {
const { message } =
await trpcVanilla.whatsApp.verifyIfPhoneNumberAvailable.query({
phoneNumberDisplayName: name,
})
if (message === 'taken') {
setIsVerifying(false)
showToast({
description: 'Phone number is already registered on Typebot',
})
return false
}
const { verificationToken } =
await trpcVanilla.whatsApp.generateVerificationToken.mutate()
setVerificationToken(verificationToken)
} catch (err) {
console.error(err)
setIsVerifying(false)
showToast({
description: 'Could not verify if phone number is available',
})
return false
}
} catch (err) {
console.error(err)
setIsVerifying(false)
showToast({
description: 'Could not get phone number info',
})
return false
}
setIsVerifying(false)
return true
}
const goToNextStep = async () => {
if (activeStep === steps.length - 1) return createMetaCredentials()
if (activeStep === 1 && !(await isTokenValid())) return
if (activeStep === 2 && !(await isPhoneNumberAvailable())) return
goToNext()
}
return (
<Modal isOpen={isOpen} onClose={onClose} size="3xl">
<ModalOverlay />
<ModalContent>
<ModalHeader>
<HStack h="40px">
{activeStep > 0 && (
<IconButton
icon={<ChevronLeftIcon />}
aria-label={'Go back'}
variant="ghost"
onClick={goToPrevious}
/>
)}
<Heading size="md">Add a WhatsApp phone number</Heading>
</HStack>
</ModalHeader>
<ModalCloseButton />
<ModalBody as={Stack} spacing="10">
<Stepper index={activeStep} size="sm" pt="4">
{steps.map((step, index) => (
<Step key={index}>
<StepIndicator>
<StepStatus
complete={<StepIcon />}
incomplete={<StepNumber />}
active={<StepNumber />}
/>
</StepIndicator>
<Box flexShrink="0">
<StepTitle>{step.title}</StepTitle>
</Box>
<StepSeparator />
</Step>
))}
</Stepper>
{activeStep === 0 && <Requirements />}
{activeStep === 1 && (
<SystemUserToken
initialToken={systemUserAccessToken}
setToken={setSystemUserAccessToken}
/>
)}
{activeStep === 2 && (
<PhoneNumber
appId={tokenInfoData?.appId}
initialPhoneNumberId={phoneNumberId}
setPhoneNumberId={setPhoneNumberId}
/>
)}
{activeStep === 3 && (
<Webhook
appId={tokenInfoData?.appId}
verificationToken={verificationToken}
phoneNumberId={phoneNumberId}
/>
)}
</ModalBody>
<ModalFooter>
<Button
onClick={goToNextStep}
colorScheme="blue"
isDisabled={
(activeStep === 1 && isEmpty(systemUserAccessToken)) ||
(activeStep === 2 && isEmpty(phoneNumberId))
}
isLoading={isVerifying || isCreating}
>
{activeStep === steps.length - 1 ? 'Submit' : 'Continue'}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}
const Requirements = () => (
<Stack spacing={4}>
<Text>
Make sure you have{' '}
<TextLink
href="https://developers.facebook.com/docs/whatsapp/cloud-api/get-started#set-up-developer-assets"
isExternal
>
created a WhatsApp Business Account
</TextLink>
. You should be able to get to this page:
</Text>
<Image
src="/images/whatsapp-quickstart-page.png"
alt="WhatsApp quickstart page"
rounded="md"
/>
<Text>
You can find your Meta apps here:{' '}
<TextLink href="https://developers.facebook.com/apps" isExternal>
https://developers.facebook.com/apps
</TextLink>
.
</Text>
</Stack>
)
const SystemUserToken = ({
initialToken,
setToken,
}: {
initialToken: string
setToken: (id: string) => void
}) => (
<OrderedList spacing={4}>
<ListItem>
Go to your{' '}
<Button
as={Link}
href="https://business.facebook.com/settings/system-users"
isExternal
rightIcon={<ExternalLinkIcon />}
size="sm"
>
System users page
</Button>
</ListItem>
<ListItem>
Create a new user by clicking on <Code>Add</Code>
</ListItem>
<ListItem>
Fill it with any name and give it the <Code>Admin</Code> role
</ListItem>
<ListItem>
<Stack>
<Text>
Click on <Code>Add assets</Code>. Under <Code>Apps</Code>, look for
your previously created app, select it and check{' '}
<Code>Manage app</Code>
</Text>
<Image
src="/images/meta-system-user-assets.png"
alt="Meta system user assets"
rounded="md"
/>
</Stack>
</ListItem>
<ListItem>
<Stack spacing={4}>
<Text>
Now, click on <Code>Generate new token</Code>. Select your app.
</Text>
<UnorderedList spacing={4}>
<ListItem>
Token expiration: <Code>Never</Code>
</ListItem>
<ListItem>
Available Permissions: <Code>whatsapp_business_messaging</Code>,{' '}
<Code>whatsapp_business_management</Code>{' '}
</ListItem>
</UnorderedList>
</Stack>
</ListItem>
<ListItem>Copy and paste the generated token:</ListItem>
<TextInput
isRequired
label="System User Token"
defaultValue={initialToken}
onChange={(val) => setToken(val.trim())}
withVariableButton={false}
debounceTimeout={0}
/>
</OrderedList>
)
const PhoneNumber = ({
appId,
initialPhoneNumberId,
setPhoneNumberId,
}: {
appId?: string
initialPhoneNumberId: string
setPhoneNumberId: (id: string) => void
}) => (
<OrderedList spacing={4}>
<ListItem>
<HStack>
<Text>
Go to your{' '}
<TextLink
href={`https://developers.facebook.com/apps/${appId}/whatsapp-business/wa-dev-console`}
isExternal
>
WhatsApp Dev Console
</TextLink>
</Text>
</HStack>
</ListItem>
<ListItem>
Add your phone number by clicking on the <Code>Add phone number</Code>{' '}
button.
</ListItem>
<ListItem>
<Stack>
<Text>
Select a phone number and paste the associated{' '}
<Code>Phone number ID</Code> and{' '}
<Code>WhatsApp Business Account ID</Code>:
</Text>
<HStack>
<TextInput
label="Phone number ID"
defaultValue={initialPhoneNumberId}
withVariableButton={false}
debounceTimeout={0}
isRequired
onChange={setPhoneNumberId}
/>
</HStack>
<Image
src="/images/whatsapp-phone-selection.png"
alt="WA phone selection"
/>
</Stack>
</ListItem>
</OrderedList>
)
const Webhook = ({
appId,
verificationToken,
phoneNumberId,
}: {
appId?: string
verificationToken: string
phoneNumberId: string
}) => {
const { workspace } = useWorkspace()
const webhookUrl = `${
env.NEXT_PUBLIC_VIEWER_INTERNAL_URL ?? getViewerUrl()
}/api/v1/workspaces/${
workspace?.id
}/whatsapp/phoneNumbers/${phoneNumberId}/webhook`
return (
<Stack spacing={6}>
<Text>
In your{' '}
<TextLink
href={`https://developers.facebook.com/apps/${appId}/whatsapp-business/wa-settings`}
isExternal
>
WhatsApp Settings page
</TextLink>
, click on the Edit button and insert the following values:
</Text>
<UnorderedList spacing={6}>
<ListItem>
<HStack>
<Text flexShrink={0}>Callback URL:</Text>
<InputGroup size="sm">
<Input type={'text'} defaultValue={webhookUrl} />
<InputRightElement width="60px">
<CopyButton size="sm" textToCopy={webhookUrl} />
</InputRightElement>
</InputGroup>
</HStack>
</ListItem>
<ListItem>
<HStack>
<Text flexShrink={0}>Verify Token:</Text>
<InputGroup size="sm">
<Input type={'text'} defaultValue={verificationToken} />
<InputRightElement width="60px">
<CopyButton size="sm" textToCopy={verificationToken} />
</InputRightElement>
</InputGroup>
</HStack>
</ListItem>
<ListItem>
<HStack>
<Text flexShrink={0}>
Webhook fields: check <Code>messages</Code>
</Text>
</HStack>
</ListItem>
</UnorderedList>
</Stack>
)
}

View File

@ -0,0 +1,233 @@
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
Heading,
ModalCloseButton,
ModalBody,
ModalFooter,
Stack,
Text,
OrderedList,
ListItem,
HStack,
useDisclosure,
Accordion,
AccordionButton,
AccordionIcon,
AccordionItem,
AccordionPanel,
Flex,
} from '@chakra-ui/react'
import { useWorkspace } from '@/features/workspace/WorkspaceProvider'
import { CredentialsDropdown } from '@/features/credentials/components/CredentialsDropdown'
import { ModalProps } from '../../EmbedButton'
import { useTypebot } from '@/features/editor/providers/TypebotProvider'
import { WhatsAppCredentialsModal } from './WhatsAppCredentialsModal'
import { TextLink } from '@/components/TextLink'
import { PublishButton } from '../../../PublishButton'
import { useParentModal } from '@/features/graph/providers/ParentModalProvider'
import { trpc } from '@/lib/trpc'
import { SwitchWithLabel } from '@/components/inputs/SwitchWithLabel'
import { isDefined } from '@typebot.io/lib/utils'
import { TableList } from '@/components/TableList'
import { Comparison, LogicalOperator } from '@typebot.io/schemas'
import { DropdownList } from '@/components/DropdownList'
import { WhatsAppComparisonItem } from './WhatsAppComparisonItem'
import { AlertInfo } from '@/components/AlertInfo'
export const WhatsAppModal = ({ isOpen, onClose }: ModalProps): JSX.Element => {
const { typebot, updateTypebot, isPublished } = useTypebot()
const { ref } = useParentModal()
const { workspace } = useWorkspace()
const {
isOpen: isCredentialsModalOpen,
onOpen,
onClose: onCredentialsModalClose,
} = useDisclosure()
const whatsAppSettings = typebot?.settings.whatsApp
const { data: phoneNumberData } = trpc.whatsApp.getPhoneNumber.useQuery(
{
credentialsId: whatsAppSettings?.credentialsId as string,
},
{
enabled: !!whatsAppSettings?.credentialsId,
}
)
const toggleEnableWhatsApp = (isChecked: boolean) => {
if (!phoneNumberData?.id) return
updateTypebot({
updates: { whatsAppPhoneNumberId: isChecked ? phoneNumberData.id : null },
save: true,
})
}
const updateCredentialsId = (credentialsId: string | undefined) => {
if (!typebot) return
updateTypebot({
updates: {
settings: {
...typebot.settings,
whatsApp: {
...typebot.settings.whatsApp,
credentialsId,
},
},
},
})
}
const updateStartConditionComparisons = (comparisons: Comparison[]) => {
if (!typebot) return
updateTypebot({
updates: {
settings: {
...typebot.settings,
whatsApp: {
...typebot.settings.whatsApp,
startCondition: {
logicalOperator:
typebot.settings.whatsApp?.startCondition?.logicalOperator ??
LogicalOperator.AND,
comparisons,
},
},
},
},
})
}
const updateStartConditionLogicalOperator = (
logicalOperator: LogicalOperator
) => {
if (!typebot) return
updateTypebot({
updates: {
settings: {
...typebot.settings,
whatsApp: {
...typebot.settings.whatsApp,
startCondition: {
comparisons:
typebot.settings.whatsApp?.startCondition?.comparisons ?? [],
logicalOperator,
},
},
},
},
})
}
return (
<Modal isOpen={isOpen} onClose={onClose} size="xl">
<ModalOverlay />
<ModalContent ref={ref}>
<ModalHeader>
<Heading size="md">WhatsApp</Heading>
</ModalHeader>
<ModalCloseButton />
<ModalBody as={Stack} spacing="6">
{!isPublished && phoneNumberData?.id && (
<AlertInfo>You have modifications that can be published.</AlertInfo>
)}
<OrderedList spacing={4} pl="4">
<ListItem>
<HStack>
<Text>Select a phone number:</Text>
{workspace && (
<>
<WhatsAppCredentialsModal
isOpen={isCredentialsModalOpen}
onClose={onCredentialsModalClose}
onNewCredentials={updateCredentialsId}
/>
<CredentialsDropdown
type="whatsApp"
workspaceId={workspace.id}
currentCredentialsId={whatsAppSettings?.credentialsId}
onCredentialsSelect={updateCredentialsId}
onCreateNewClick={onOpen}
credentialsName="WA phone number"
size="sm"
/>
</>
)}
</HStack>
</ListItem>
{typebot?.settings.whatsApp?.credentialsId && (
<>
<ListItem>
<Accordion allowToggle>
<AccordionItem>
<AccordionButton justifyContent="space-between">
Start flow only if
<AccordionIcon />
</AccordionButton>
<AccordionPanel as={Stack} spacing="4" pt="4">
<TableList<Comparison>
initialItems={
whatsAppSettings?.startCondition?.comparisons ?? []
}
onItemsChange={updateStartConditionComparisons}
Item={WhatsAppComparisonItem}
ComponentBetweenItems={() => (
<Flex justify="center">
<DropdownList
currentItem={
whatsAppSettings?.startCondition
?.logicalOperator
}
onItemSelect={
updateStartConditionLogicalOperator
}
items={Object.values(LogicalOperator)}
size="sm"
/>
</Flex>
)}
addLabel="Add a comparison"
/>
</AccordionPanel>
</AccordionItem>
</Accordion>
</ListItem>
<ListItem>
<HStack>
<Text>Publish your bot:</Text>
<PublishButton size="sm" isMoreMenuDisabled />
</HStack>
</ListItem>
<ListItem>
<SwitchWithLabel
label="Enable WhatsApp integration"
initialValue={
isDefined(typebot?.whatsAppPhoneNumberId) ? true : false
}
onCheckChange={toggleEnableWhatsApp}
justifyContent="flex-start"
/>
</ListItem>
{phoneNumberData?.id && (
<ListItem>
<TextLink
href={`https://wa.me/${phoneNumberData.name}?text=Start`}
isExternal
>
Try it out
</TextLink>
</ListItem>
)}
</>
)}
</OrderedList>
</ModalBody>
<ModalFooter />
</ModalContent>
</Modal>
)
}

View File

@ -76,7 +76,7 @@ const parseWordpressShortcode = ({
publicId: string publicId: string
}) => { }) => {
return `[typebot typebot="${publicId}"${ return `[typebot typebot="${publicId}"${
isCloudProdInstance ? '' : ` host="${getViewerUrl()}"` isCloudProdInstance() ? '' : ` host="${getViewerUrl()}"`
}${width ? ` width="${width}"` : ''}${height ? ` height="${height}"` : ''}] }${width ? ` width="${width}"` : ''}${height ? ` height="${height}"` : ''}]
` `
} }

View File

@ -41,7 +41,7 @@ export const parseReactBotProps = ({ typebot, apiHost }: BotProps) => {
return `${typebotLine} ${apiHostLine}` return `${typebotLine} ${apiHostLine}`
} }
export const typebotImportCode = isCloudProdInstance export const typebotImportCode = isCloudProdInstance()
? `import Typebot from 'https://cdn.jsdelivr.net/npm/@typebot.io/js@0.1/dist/web.js'` ? `import Typebot from 'https://cdn.jsdelivr.net/npm/@typebot.io/js@0.1/dist/web.js'`
: `import Typebot from 'https://cdn.jsdelivr.net/npm/@typebot.io/js@${packageJson.version}/dist/web.js'` : `import Typebot from 'https://cdn.jsdelivr.net/npm/@typebot.io/js@${packageJson.version}/dist/web.js'`
@ -64,6 +64,6 @@ export const parseApiHost = (
export const parseApiHostValue = ( export const parseApiHostValue = (
customDomain: Typebot['customDomain'] | undefined customDomain: Typebot['customDomain'] | undefined
) => { ) => {
if (isCloudProdInstance) return if (isCloudProdInstance()) return
return parseApiHost(customDomain) return parseApiHost(customDomain)
} }

View File

@ -23,4 +23,5 @@ export const convertPublicTypebotToTypebot = (
isClosed: existingTypebot.isClosed, isClosed: existingTypebot.isClosed,
resultsTablePreferences: existingTypebot.resultsTablePreferences, resultsTablePreferences: existingTypebot.resultsTablePreferences,
selectedThemeTemplateId: existingTypebot.selectedThemeTemplateId, selectedThemeTemplateId: existingTypebot.selectedThemeTemplateId,
whatsAppPhoneNumberId: existingTypebot.whatsAppPhoneNumberId,
}) })

View File

@ -14,6 +14,7 @@ export const processTelemetryEvent = authenticatedProcedure
path: '/t/process', path: '/t/process',
description: description:
"Only used for the cloud version of Typebot. It's the way it processes telemetry events and inject it to thrid-party services.", "Only used for the cloud version of Typebot. It's the way it processes telemetry events and inject it to thrid-party services.",
tags: ['Telemetry'],
}, },
}) })
.input( .input(
@ -26,19 +27,19 @@ export const processTelemetryEvent = authenticatedProcedure
message: z.literal('Events injected'), message: z.literal('Events injected'),
}) })
) )
.query(async ({ input: { events }, ctx: { user } }) => { .mutation(async ({ input: { events }, ctx: { user } }) => {
if (user.email !== env.ADMIN_EMAIL) if (user.email !== env.ADMIN_EMAIL)
throw new TRPCError({ throw new TRPCError({
code: 'BAD_REQUEST', code: 'BAD_REQUEST',
message: 'Only app admin can process telemetry events', message: 'Only app admin can process telemetry events',
}) })
if (!env.POSTHOG_API_KEY) if (!env.NEXT_PUBLIC_POSTHOG_KEY)
throw new TRPCError({ throw new TRPCError({
code: 'BAD_REQUEST', code: 'BAD_REQUEST',
message: 'Server does not have POSTHOG_API_KEY configured', message: 'Server does not have POSTHOG_API_KEY configured',
}) })
const client = new PostHog(env.POSTHOG_API_KEY, { const client = new PostHog(env.NEXT_PUBLIC_POSTHOG_KEY, {
host: 'https://eu.posthog.com', host: env.NEXT_PUBLIC_POSTHOG_HOST,
}) })
events.forEach(async (event) => { events.forEach(async (event) => {

View File

@ -0,0 +1,34 @@
import { env } from '@typebot.io/env'
import posthog from 'posthog-js'
export const initPostHogIfEnabled = () => {
if (typeof window === 'undefined') return
const posthogKey = env.NEXT_PUBLIC_POSTHOG_KEY
if (!posthogKey) return
posthog.init(posthogKey, {
api_host: env.NEXT_PUBLIC_POSTHOG_HOST,
loaded: (posthog) => {
if (process.env.NODE_ENV === 'development') posthog.debug()
},
capture_pageview: false,
capture_pageleave: false,
autocapture: false,
})
}
export const identifyUser = (userId: string) => {
if (!posthog.__loaded) return
posthog.identify(userId)
}
export const getFeatureFlags = () => {
return posthog.__loaded &&
posthog.isFeatureEnabled('whatsApp', { send_event: false })
? ['whatsApp']
: []
}
export { posthog }

View File

@ -30,6 +30,7 @@ export const updateTypebot = authenticatedProcedure
typebotSchema._def.schema typebotSchema._def.schema
.pick({ .pick({
isClosed: true, isClosed: true,
whatsAppPhoneNumberId: true,
}) })
.partial() .partial()
), ),
@ -68,6 +69,7 @@ export const updateTypebot = authenticatedProcedure
plan: true, plan: true,
}, },
}, },
whatsAppPhoneNumberId: true,
updatedAt: true, updatedAt: true,
}, },
}) })
@ -101,7 +103,7 @@ export const updateTypebot = authenticatedProcedure
}) })
if (typebot.publicId) { if (typebot.publicId) {
if (isCloudProdInstance && typebot.publicId.length < 4) if (isCloudProdInstance() && typebot.publicId.length < 4)
throw new TRPCError({ throw new TRPCError({
code: 'BAD_REQUEST', code: 'BAD_REQUEST',
message: 'Public id should be at least 4 characters long', message: 'Public id should be at least 4 characters long',
@ -148,6 +150,7 @@ export const updateTypebot = authenticatedProcedure
customDomain: customDomain:
typebot.customDomain === null ? null : typebot.customDomain, typebot.customDomain === null ? null : typebot.customDomain,
isClosed: typebot.isClosed, isClosed: typebot.isClosed,
whatsAppPhoneNumberId: typebot.whatsAppPhoneNumberId ?? undefined,
}, },
}) })

View File

@ -0,0 +1,31 @@
import { authenticatedProcedure } from '@/helpers/server/trpc'
import { z } from 'zod'
import prisma from '@/lib/prisma'
import { createId } from '@paralleldrive/cuid2'
export const generateVerificationToken = authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/verficiationTokens',
protect: true,
},
})
.input(z.void())
.output(
z.object({
verificationToken: z.string(),
})
)
.mutation(async () => {
const oneHourLater = new Date(Date.now() + 1000 * 60 * 60)
const verificationToken = await prisma.verificationToken.create({
data: {
token: createId(),
expires: oneHourLater,
identifier: 'whatsapp webhook',
},
})
return { verificationToken: verificationToken.token }
})

View File

@ -0,0 +1,78 @@
import { authenticatedProcedure } from '@/helpers/server/trpc'
import { z } from 'zod'
import got from 'got'
import prisma from '@/lib/prisma'
import { decrypt } from '@typebot.io/lib/api'
import { TRPCError } from '@trpc/server'
import { WhatsAppCredentials } from '@typebot.io/schemas/features/whatsapp'
import { parsePhoneNumber } from 'libphonenumber-js'
const inputSchema = z.object({
credentialsId: z.string().optional(),
systemToken: z.string().optional(),
phoneNumberId: z.string().optional(),
})
export const getPhoneNumber = authenticatedProcedure
.meta({
openapi: {
method: 'GET',
path: '/whatsapp/phoneNumber',
protect: true,
},
})
.input(inputSchema)
.output(
z.object({
id: z.string(),
name: z.string(),
})
)
.query(async ({ input, ctx: { user } }) => {
const credentials = await getCredentials(user.id, input)
if (!credentials)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Credentials not found',
})
const { display_phone_number } = (await got(
`https://graph.facebook.com/v17.0/${credentials.phoneNumberId}`,
{
headers: {
Authorization: `Bearer ${credentials.systemUserAccessToken}`,
},
}
).json()) as {
display_phone_number: string
}
return {
id: credentials.phoneNumberId,
name: parsePhoneNumber(display_phone_number)
.formatInternational()
.replace(/\s/g, ''),
}
})
const getCredentials = async (
userId: string,
input: z.infer<typeof inputSchema>
): Promise<WhatsAppCredentials['data'] | undefined> => {
if (input.systemToken && input.phoneNumberId)
return {
systemUserAccessToken: input.systemToken,
phoneNumberId: input.phoneNumberId,
}
if (!input.credentialsId) return
const credentials = await prisma.credentials.findUnique({
where: {
id: input.credentialsId,
workspace: { members: { some: { userId } } },
},
})
if (!credentials) return
return (await decrypt(
credentials.data,
credentials.iv
)) as WhatsAppCredentials['data']
}

View File

@ -0,0 +1,88 @@
import { authenticatedProcedure } from '@/helpers/server/trpc'
import { z } from 'zod'
import got from 'got'
import { TRPCError } from '@trpc/server'
import { WhatsAppCredentials } from '@typebot.io/schemas/features/whatsapp'
import prisma from '@/lib/prisma'
import { decrypt } from '@typebot.io/lib/api/encryption'
const inputSchema = z.object({
token: z.string().optional(),
credentialsId: z.string().optional(),
})
export const getSystemTokenInfo = authenticatedProcedure
.meta({
openapi: {
method: 'GET',
path: '/whatsapp/systemToken',
protect: true,
},
})
.input(inputSchema)
.output(
z.object({
appId: z.string(),
appName: z.string(),
expiresAt: z.number(),
scopes: z.array(z.string()),
})
)
.query(async ({ input, ctx: { user } }) => {
if (!input.token && !input.credentialsId)
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Either token or credentialsId must be provided',
})
const credentials = await getCredentials(user.id, input)
if (!credentials)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Credentials not found',
})
const {
data: { expires_at, scopes, app_id, application },
} = (await got(
`https://graph.facebook.com/v17.0/debug_token?input_token=${credentials.systemUserAccessToken}`,
{
headers: {
Authorization: `Bearer ${credentials.systemUserAccessToken}`,
},
}
).json()) as {
data: {
app_id: string
application: string
expires_at: number
scopes: string[]
}
}
return {
appId: app_id,
appName: application,
expiresAt: expires_at,
scopes,
}
})
const getCredentials = async (
userId: string,
input: z.infer<typeof inputSchema>
): Promise<Omit<WhatsAppCredentials['data'], 'phoneNumberId'> | undefined> => {
if (input.token)
return {
systemUserAccessToken: input.token,
}
const credentials = await prisma.credentials.findUnique({
where: {
id: input.credentialsId,
workspace: { members: { some: { userId } } },
},
})
if (!credentials) return
return (await decrypt(
credentials.data,
credentials.iv
)) as WhatsAppCredentials['data']
}

View File

@ -0,0 +1,12 @@
import { router } from '@/helpers/server/trpc'
import { getPhoneNumber } from './getPhoneNumber'
import { getSystemTokenInfo } from './getSystemTokenInfo'
import { verifyIfPhoneNumberAvailable } from './verifyIfPhoneNumberAvailable'
import { generateVerificationToken } from './generateVerificationToken'
export const whatsAppRouter = router({
getPhoneNumber,
getSystemTokenInfo,
verifyIfPhoneNumberAvailable,
generateVerificationToken,
})

View File

@ -0,0 +1,29 @@
import { authenticatedProcedure } from '@/helpers/server/trpc'
import { z } from 'zod'
import prisma from '@/lib/prisma'
export const verifyIfPhoneNumberAvailable = authenticatedProcedure
.meta({
openapi: {
method: 'GET',
path: '/whatsapp/phoneNumber/{phoneNumberDisplayName}/available',
protect: true,
},
})
.input(z.object({ phoneNumberDisplayName: z.string() }))
.output(
z.object({
message: z.enum(['available', 'taken']),
})
)
.query(async ({ input: { phoneNumberDisplayName } }) => {
const existingWhatsAppCredentials = await prisma.credentials.findFirst({
where: {
type: 'whatsApp',
name: phoneNumberDisplayName,
},
})
if (existingWhatsAppCredentials) return { message: 'taken' }
return { message: 'available' }
})

View File

@ -1,3 +1,4 @@
import { env } from '@typebot.io/env'
import { MemberInWorkspace, User } from '@typebot.io/prisma' import { MemberInWorkspace, User } from '@typebot.io/prisma'
export const isReadWorkspaceFobidden = ( export const isReadWorkspaceFobidden = (
@ -7,7 +8,7 @@ export const isReadWorkspaceFobidden = (
user: Pick<User, 'email' | 'id'> user: Pick<User, 'email' | 'id'>
) => { ) => {
if ( if (
process.env.ADMIN_EMAIL === user.email || env.ADMIN_EMAIL === user.email ||
workspace.members.find((member) => member.userId === user.id) workspace.members.find((member) => member.userId === user.id)
) )
return false return false

View File

@ -1,4 +1,8 @@
export const isCloudProdInstance = import { env } from '@typebot.io/env'
(typeof window !== 'undefined' &&
window.location.hostname === 'app.typebot.io') || export const isCloudProdInstance = () => {
process.env.NEXTAUTH_URL === 'https://app.typebot.io' if (typeof window !== 'undefined') {
return window.location.hostname === 'app.typebot.io'
}
return env.NEXTAUTH_URL === 'https://app.typebot.io'
}

View File

@ -3,6 +3,7 @@ import { webhookRouter } from '@/features/blocks/integrations/webhook/api/router
import { getLinkedTypebots } from '@/features/blocks/logic/typebotLink/api/getLinkedTypebots' import { getLinkedTypebots } from '@/features/blocks/logic/typebotLink/api/getLinkedTypebots'
import { credentialsRouter } from '@/features/credentials/api/router' import { credentialsRouter } from '@/features/credentials/api/router'
import { getAppVersionProcedure } from '@/features/dashboard/api/getAppVersionProcedure' import { getAppVersionProcedure } from '@/features/dashboard/api/getAppVersionProcedure'
import { sendWhatsAppInitialMessage } from '@/features/preview/api/sendWhatsAppInitialMessage'
import { resultsRouter } from '@/features/results/api/router' import { resultsRouter } from '@/features/results/api/router'
import { processTelemetryEvent } from '@/features/telemetry/api/processTelemetryEvent' import { processTelemetryEvent } from '@/features/telemetry/api/processTelemetryEvent'
import { themeRouter } from '@/features/theme/api/router' import { themeRouter } from '@/features/theme/api/router'
@ -12,12 +13,14 @@ import { router } from '../../trpc'
import { analyticsRouter } from '@/features/analytics/api/router' import { analyticsRouter } from '@/features/analytics/api/router'
import { collaboratorsRouter } from '@/features/collaboration/api/router' import { collaboratorsRouter } from '@/features/collaboration/api/router'
import { customDomainsRouter } from '@/features/customDomains/api/router' import { customDomainsRouter } from '@/features/customDomains/api/router'
import { whatsAppRouter } from '@/features/whatsapp/router'
export const trpcRouter = router({ export const trpcRouter = router({
getAppVersionProcedure, getAppVersionProcedure,
processTelemetryEvent, processTelemetryEvent,
getLinkedTypebots, getLinkedTypebots,
analytics: analyticsRouter, analytics: analyticsRouter,
sendWhatsAppInitialMessage,
workspace: workspaceRouter, workspace: workspaceRouter,
typebot: typebotRouter, typebot: typebotRouter,
webhook: webhookRouter, webhook: webhookRouter,
@ -27,6 +30,7 @@ export const trpcRouter = router({
theme: themeRouter, theme: themeRouter,
collaborators: collaboratorsRouter, collaborators: collaboratorsRouter,
customDomains: customDomainsRouter, customDomains: customDomainsRouter,
whatsApp: whatsAppRouter,
}) })
export type AppRouter = typeof trpcRouter export type AppRouter = typeof trpcRouter

View File

@ -20,6 +20,9 @@ import { TypebotProvider } from '@/features/editor/providers/TypebotProvider'
import { WorkspaceProvider } from '@/features/workspace/WorkspaceProvider' import { WorkspaceProvider } from '@/features/workspace/WorkspaceProvider'
import { isCloudProdInstance } from '@/helpers/isCloudProdInstance' import { isCloudProdInstance } from '@/helpers/isCloudProdInstance'
import { initPostHogIfEnabled } from '@/features/telemetry/posthog'
initPostHogIfEnabled()
const { ToastContainer, toast } = createStandaloneToast(customTheme) const { ToastContainer, toast } = createStandaloneToast(customTheme)
const App = ({ Component, pageProps }: AppProps) => { const App = ({ Component, pageProps }: AppProps) => {
@ -59,7 +62,7 @@ const App = ({ Component, pageProps }: AppProps) => {
<TypebotProvider typebotId={typebotId}> <TypebotProvider typebotId={typebotId}>
<WorkspaceProvider typebotId={typebotId}> <WorkspaceProvider typebotId={typebotId}>
<Component {...pageProps} /> <Component {...pageProps} />
{!pathname.endsWith('edit') && isCloudProdInstance && ( {!pathname.endsWith('edit') && isCloudProdInstance() && (
<SupportBubble /> <SupportBubble />
)} )}
<NewVersionPopup /> <NewVersionPopup />

View File

@ -0,0 +1,31 @@
# WhatsApp
WhatsApp is currently available as a private beta test. If you'd like to try it out, reach out to support@typebot.io.
## Preview
You can preview and test your bot by clicking on the Preview button in the editor and change the runtime to "WhatsApp".
## Publish
Head over to the Share tab of your bot and click on the WhatsApp button to get the integration instructions of your bot.
## Limitations
WhatsApp environment have some limitations that you need to keep in mind when building the bot:
- GIF and SVG image files are not supported. They won't be displayed.
- Buttons content can't be longer than 20 characters
- Incompatible blocks, if present, they will be skipped:
- Payment input block
- Chatwoot block
- Script block
- Google Analytics block
- Meta Pixel blocks
- Execute on client options
## Contact information
You can automatically assign contact name and phone number to a variable in your bot using a Set variable block with the dedicated system values:
<img src="/img/whatsapp/contact-var.png" alt="WhatsApp contact system variables" />

View File

@ -192,6 +192,37 @@ Used to search for images. You can create a Giphy app [here](https://unsplash.co
| NEXT_PUBLIC_UNSPLASH_APP_NAME | | Unsplash App name | | NEXT_PUBLIC_UNSPLASH_APP_NAME | | Unsplash App name |
| NEXT_PUBLIC_UNSPLASH_ACCESS_KEY | | Unsplash API key | | NEXT_PUBLIC_UNSPLASH_ACCESS_KEY | | Unsplash API key |
## WhatsApp (Preview)
In order to be able to test your bot on WhatsApp from the Preview drawer, you need to set up a WhatsApp business app.
<details><summary><h4>Requirements</h4></summary>
<p>
1. Make sure you have [created a WhatsApp Business Account](https://developers.facebook.com/docs/whatsapp/cloud-api/get-started#set-up-developer-assets).
2. Go to your [System users page](https://business.facebook.com/settings/system-users) and create a new system user that has access to the related.
- Token expiration: `Never`
- Available Permissions: `whatsapp_business_messaging`, `whatsapp_business_management`
3. The generated token will be used as `META_SYSTEM_USER_TOKEN` in your viewer configuration.
4. Click on `Add assets`. Under `Apps`, look for your app, select it and check `Manage app`
5. Go to your WhatsApp Dev Console
<img src="/img/whatsapp/dev-console.png" alt="WhatsApp dev console" />
6. Add your phone number by clicking on the `Add phone number` button.
7. Select the newly created phone number in the `From` dropdown list. This will be used as `WHATSAPP_PREVIEW_FROM_PHONE_NUMBER_ID` in your viewer configuration.
8. Head over to `Quickstart > Configuration`. Edit the webhook URL to `$NEXT_PUBLIC_VIEWER_URL/api/v1/whatsapp/preview/webhook`. Set the Verify token to `$ENCRYPTION_SECRET` and click on `Verify and save`.
9. Add the `messages` webhook field.
</p></details>
| Parameter | Default | Description |
| ------------------------------------- | ------- | ------------------------------------------------------- |
| META_SYSTEM_USER_TOKEN | | The system user token used to send WhatsApp messages |
| WHATSAPP_PREVIEW_FROM_PHONE_NUMBER_ID | | The phone number ID from which the message will be sent |
## Others ## Others
<details><summary><h3>Show</h3></summary> <details><summary><h3>Show</h3></summary>
@ -273,10 +304,10 @@ These can also be added to the `viewer` environment
<details><summary><h4>PostHog</h4></summary> <details><summary><h4>PostHog</h4></summary>
<p> <p>
| Parameter | Default | Description | | Parameter | Default | Description |
| ---------------- | ------- | ---------------- | | ---------------------------- | ----------------------- | ---------------- |
| POSTHOG_API_KEY | | PostHog API Key | | NEXT_PUBLIC_POSTHOG_API_KEY | | PostHog API Key |
| POSTHOG_API_HOST | | PostHog API Host | | NEXT_PUBLIC_POSTHOG_API_HOST | https://app.posthog.com | PostHog API Host |
</p></details> </p></details>

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 KiB

View File

@ -17,8 +17,10 @@
"@typebot.io/nextjs": "workspace:*", "@typebot.io/nextjs": "workspace:*",
"@typebot.io/prisma": "workspace:*", "@typebot.io/prisma": "workspace:*",
"ai": "2.1.32", "ai": "2.1.32",
"@udecode/plate-common": "^21.1.5",
"aws-sdk": "2.1415.0", "aws-sdk": "2.1415.0",
"bot-engine": "workspace:*", "bot-engine": "workspace:*",
"chrono-node": "^2.6.4",
"cors": "2.8.5", "cors": "2.8.5",
"eventsource-parser": "^1.0.0", "eventsource-parser": "^1.0.0",
"google-spreadsheet": "4.0.2", "google-spreadsheet": "4.0.2",
@ -33,6 +35,7 @@
"qs": "6.11.2", "qs": "6.11.2",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"remark-slate": "^1.8.6",
"stripe": "12.13.0", "stripe": "12.13.0",
"trpc-openapi": "1.2.0" "trpc-openapi": "1.2.0"
}, },

View File

@ -0,0 +1,87 @@
import { ChoiceInputBlock, SessionState } from '@typebot.io/schemas'
import { injectVariableValuesInButtonsInputBlock } from './injectVariableValuesInButtonsInputBlock'
import { ParsedReply } from '@/features/chat/types'
export const parseButtonsReply =
(state: SessionState) =>
(inputValue: string, block: ChoiceInputBlock): ParsedReply => {
const displayedItems =
injectVariableValuesInButtonsInputBlock(state)(block).items
if (block.options.isMultipleChoice) {
const longestItemsFirst = [...displayedItems].sort(
(a, b) => (b.content?.length ?? 0) - (a.content?.length ?? 0)
)
const matchedItemsByContent = longestItemsFirst.reduce<{
strippedInput: string
matchedItemIds: string[]
}>(
(acc, item) => {
if (
item.content &&
acc.strippedInput.toLowerCase().includes(item.content.toLowerCase())
)
return {
strippedInput: acc.strippedInput.replace(item.content ?? '', ''),
matchedItemIds: [...acc.matchedItemIds, item.id],
}
return acc
},
{
strippedInput: inputValue.trim(),
matchedItemIds: [],
}
)
const remainingItems = displayedItems.filter(
(item) => !matchedItemsByContent.matchedItemIds.includes(item.id)
)
const matchedItemsByIndex = remainingItems.reduce<{
strippedInput: string
matchedItemIds: string[]
}>(
(acc, item, idx) => {
if (acc.strippedInput.includes(`${idx + 1}`))
return {
strippedInput: acc.strippedInput.replace(`${idx + 1}`, ''),
matchedItemIds: [...acc.matchedItemIds, item.id],
}
return acc
},
{
strippedInput: matchedItemsByContent.strippedInput,
matchedItemIds: [],
}
)
const matchedItems = displayedItems.filter((item) =>
[
...matchedItemsByContent.matchedItemIds,
...matchedItemsByIndex.matchedItemIds,
].includes(item.id)
)
if (matchedItems.length === 0) return { status: 'fail' }
return {
status: 'success',
reply: matchedItems.map((item) => item.content).join(', '),
}
}
if (state.whatsApp) {
const matchedItem = displayedItems.find((item) => item.id === inputValue)
if (!matchedItem) return { status: 'fail' }
return {
status: 'success',
reply: matchedItem.content ?? '',
}
}
const longestItemsFirst = [...displayedItems].sort(
(a, b) => (b.content?.length ?? 0) - (a.content?.length ?? 0)
)
const matchedItem = longestItemsFirst.find(
(item) =>
item.content &&
inputValue.toLowerCase().trim() === item.content.toLowerCase().trim()
)
if (!matchedItem) return { status: 'fail' }
return {
status: 'success',
reply: matchedItem.content ?? '',
}
}

View File

@ -0,0 +1,28 @@
import { ParsedReply } from '@/features/chat/types'
import { DateInputBlock } from '@typebot.io/schemas'
import { parse as chronoParse } from 'chrono-node'
export const parseDateReply = (
reply: string,
block: DateInputBlock
): ParsedReply => {
const parsedDate = chronoParse(reply)
if (parsedDate.length === 0) return { status: 'fail' }
const formatOptions: Intl.DateTimeFormatOptions = {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: block.options.hasTime ? '2-digit' : undefined,
minute: block.options.hasTime ? '2-digit' : undefined,
}
const startDate = parsedDate[0].start
.date()
.toLocaleString(undefined, formatOptions)
const endDate = parsedDate[0].end
?.date()
.toLocaleString(undefined, formatOptions)
return {
status: 'success',
reply: block.options.isRange ? `${startDate} to ${endDate}` : startDate,
}
}

View File

@ -1,26 +0,0 @@
export const parseReadableDate = ({
from,
to,
hasTime,
isRange,
}: {
from: string
to: string
hasTime?: boolean
isRange?: boolean
}) => {
const currentLocale = window.navigator.language
const formatOptions: Intl.DateTimeFormatOptions = {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: hasTime ? '2-digit' : undefined,
minute: hasTime ? '2-digit' : undefined,
}
const fromReadable = new Date(from).toLocaleString(
currentLocale,
formatOptions
)
const toReadable = new Date(to).toLocaleString(currentLocale, formatOptions)
return `${fromReadable}${isRange ? ` to ${toReadable}` : ''}`
}

View File

@ -10,7 +10,7 @@ import {
} from '@typebot.io/schemas' } from '@typebot.io/schemas'
import { byId, isDefined } from '@typebot.io/lib' import { byId, isDefined } from '@typebot.io/lib'
import { z } from 'zod' import { z } from 'zod'
import { generatePresignedUrl } from '@typebot.io/lib/api/storage' import { generatePresignedUrl } from '@typebot.io/lib/api/generatePresignedUrl'
import { env } from '@typebot.io/env' import { env } from '@typebot.io/env'
export const getUploadUrl = publicProcedure export const getUploadUrl = publicProcedure

View File

@ -0,0 +1 @@
export const validateNumber = (inputValue: string) => !isNaN(Number(inputValue))

View File

@ -1,4 +1,16 @@
import { parsePhoneNumber } from 'libphonenumber-js' import {
CountryCode,
findPhoneNumbersInText,
isSupportedCountry,
} from 'libphonenumber-js'
export const formatPhoneNumber = (phoneNumber: string) => export const formatPhoneNumber = (
parsePhoneNumber(phoneNumber).formatInternational().replaceAll(' ', '') phoneNumber: string,
defaultCountryCode?: string
) =>
findPhoneNumbersInText(
phoneNumber,
defaultCountryCode && isSupportedCountry(defaultCountryCode)
? (defaultCountryCode as CountryCode)
: undefined
).at(0)?.number.number

View File

@ -1,4 +0,0 @@
import { isValidPhoneNumber } from 'libphonenumber-js'
export const validatePhoneNumber = (phoneNumber: string) =>
isValidPhoneNumber(phoneNumber)

View File

@ -0,0 +1,95 @@
import { PictureChoiceBlock, SessionState } from '@typebot.io/schemas'
import { ParsedReply } from '@/features/chat/types'
import { injectVariableValuesInPictureChoiceBlock } from './injectVariableValuesInPictureChoiceBlock'
export const parsePictureChoicesReply =
(state: SessionState) =>
(inputValue: string, block: PictureChoiceBlock): ParsedReply => {
const displayedItems = injectVariableValuesInPictureChoiceBlock(
state.typebotsQueue[0].typebot.variables
)(block).items
if (block.options.isMultipleChoice) {
const longestItemsFirst = [...displayedItems].sort(
(a, b) => (b.title?.length ?? 0) - (a.title?.length ?? 0)
)
const matchedItemsByContent = longestItemsFirst.reduce<{
strippedInput: string
matchedItemIds: string[]
}>(
(acc, item) => {
if (
item.title &&
acc.strippedInput.toLowerCase().includes(item.title.toLowerCase())
)
return {
strippedInput: acc.strippedInput.replace(item.title ?? '', ''),
matchedItemIds: [...acc.matchedItemIds, item.id],
}
return acc
},
{
strippedInput: inputValue.trim(),
matchedItemIds: [],
}
)
const remainingItems = displayedItems.filter(
(item) => !matchedItemsByContent.matchedItemIds.includes(item.id)
)
const matchedItemsByIndex = remainingItems.reduce<{
strippedInput: string
matchedItemIds: string[]
}>(
(acc, item, idx) => {
if (acc.strippedInput.includes(`${idx + 1}`))
return {
strippedInput: acc.strippedInput.replace(`${idx + 1}`, ''),
matchedItemIds: [...acc.matchedItemIds, item.id],
}
return acc
},
{
strippedInput: matchedItemsByContent.strippedInput,
matchedItemIds: [],
}
)
const matchedItems = displayedItems.filter((item) =>
[
...matchedItemsByContent.matchedItemIds,
...matchedItemsByIndex.matchedItemIds,
].includes(item.id)
)
if (matchedItems.length === 0) return { status: 'fail' }
return {
status: 'success',
reply: matchedItems
.map((item) => item.title ?? item.pictureSrc ?? '')
.join(', '),
}
}
if (state.whatsApp) {
const matchedItem = displayedItems.find((item) => item.id === inputValue)
if (!matchedItem) return { status: 'fail' }
return {
status: 'success',
reply: matchedItem.title ?? matchedItem.pictureSrc ?? '',
}
}
const longestItemsFirst = [...displayedItems].sort(
(a, b) => (b.title?.length ?? 0) - (a.title?.length ?? 0)
)
const matchedItem = longestItemsFirst.find(
(item) =>
item.title &&
item.title
.toLowerCase()
.trim()
.includes(inputValue.toLowerCase().trim())
)
if (!matchedItem) return { status: 'fail' }
return {
status: 'success',
reply: matchedItem.title ?? matchedItem.pictureSrc ?? '',
}
}

View File

@ -0,0 +1,4 @@
import { RatingInputBlock } from '@typebot.io/schemas'
export const validateRatingReply = (reply: string, block: RatingInputBlock) =>
Number(reply) <= block.options.length

View File

@ -74,6 +74,7 @@ export const executeChatwootBlock = (
state: SessionState, state: SessionState,
block: ChatwootBlock block: ChatwootBlock
): ExecuteIntegrationResponse => { ): ExecuteIntegrationResponse => {
if (state.whatsApp) return { outgoingEdgeId: block.outgoingEdgeId }
const { typebot, resultId } = state.typebotsQueue[0] const { typebot, resultId } = state.typebotsQueue[0]
const chatwootCode = const chatwootCode =
block.options.task === 'Close widget' block.options.task === 'Close widget'

View File

@ -7,7 +7,8 @@ export const executeGoogleAnalyticsBlock = (
block: GoogleAnalyticsBlock block: GoogleAnalyticsBlock
): ExecuteIntegrationResponse => { ): ExecuteIntegrationResponse => {
const { typebot, resultId } = state.typebotsQueue[0] const { typebot, resultId } = state.typebotsQueue[0]
if (!resultId) return { outgoingEdgeId: block.outgoingEdgeId } if (!resultId || state.whatsApp)
return { outgoingEdgeId: block.outgoingEdgeId }
const googleAnalytics = deepParseVariables(typebot.variables, { const googleAnalytics = deepParseVariables(typebot.variables, {
guessCorrectTypes: true, guessCorrectTypes: true,
removeEmptyStrings: true, removeEmptyStrings: true,

View File

@ -72,7 +72,8 @@ export const createChatCompletionOpenAI = async (
if ( if (
isPlaneteScale() && isPlaneteScale() &&
isCredentialsV2(credentials) && isCredentialsV2(credentials) &&
newSessionState.isStreamEnabled newSessionState.isStreamEnabled &&
!newSessionState.whatsApp
) { ) {
const assistantMessageVariableName = typebot.variables.find( const assistantMessageVariableName = typebot.variables.find(
(variable) => (variable) =>

View File

@ -7,7 +7,12 @@ export const executePixelBlock = (
block: PixelBlock block: PixelBlock
): ExecuteIntegrationResponse => { ): ExecuteIntegrationResponse => {
const { typebot, resultId } = state.typebotsQueue[0] const { typebot, resultId } = state.typebotsQueue[0]
if (!resultId || !block.options.pixelId || !block.options.eventType) if (
!resultId ||
!block.options.pixelId ||
!block.options.eventType ||
state.whatsApp
)
return { outgoingEdgeId: block.outgoingEdgeId } return { outgoingEdgeId: block.outgoingEdgeId }
const pixel = deepParseVariables(typebot.variables, { const pixel = deepParseVariables(typebot.variables, {
guessCorrectTypes: true, guessCorrectTypes: true,

View File

@ -58,7 +58,7 @@ export const executeWebhookBlock = async (
}) })
return { outgoingEdgeId: block.outgoingEdgeId, logs } return { outgoingEdgeId: block.outgoingEdgeId, logs }
} }
if (block.options.isExecutedOnClient) if (block.options.isExecutedOnClient && !state.whatsApp)
return { return {
outgoingEdgeId: block.outgoingEdgeId, outgoingEdgeId: block.outgoingEdgeId,
clientSideActions: [ clientSideActions: [

View File

@ -9,7 +9,8 @@ export const executeScript = (
block: ScriptBlock block: ScriptBlock
): ExecuteLogicResponse => { ): ExecuteLogicResponse => {
const { variables } = state.typebotsQueue[0].typebot const { variables } = state.typebotsQueue[0].typebot
if (!block.options.content) return { outgoingEdgeId: block.outgoingEdgeId } if (!block.options.content || state.whatsApp)
return { outgoingEdgeId: block.outgoingEdgeId }
const scriptToExecute = parseScriptToExecuteClientSideAction( const scriptToExecute = parseScriptToExecuteClientSideAction(
variables, variables,

View File

@ -15,12 +15,11 @@ export const executeSetVariable = (
return { return {
outgoingEdgeId: block.outgoingEdgeId, outgoingEdgeId: block.outgoingEdgeId,
} }
const expressionToEvaluate = getExpressionToEvaluate( const expressionToEvaluate = getExpressionToEvaluate(state)(block.options)
state.typebotsQueue[0].resultId
)(block.options)
const isCustomValue = !block.options.type || block.options.type === 'Custom' const isCustomValue = !block.options.type || block.options.type === 'Custom'
if ( if (
expressionToEvaluate && expressionToEvaluate &&
!state.whatsApp &&
((isCustomValue && block.options.isExecutedOnClient) || ((isCustomValue && block.options.isExecutedOnClient) ||
block.options.type === 'Moment of the day') block.options.type === 'Moment of the day')
) { ) {
@ -73,9 +72,13 @@ const evaluateSetVariableExpression =
} }
const getExpressionToEvaluate = const getExpressionToEvaluate =
(resultId: string | undefined) => (state: SessionState) =>
(options: SetVariableBlock['options']): string | null => { (options: SetVariableBlock['options']): string | null => {
switch (options.type) { switch (options.type) {
case 'Contact name':
return state.whatsApp?.contact.name ?? ''
case 'Phone number':
return state.whatsApp?.contact.phoneNumber ?? ''
case 'Now': case 'Now':
case 'Today': case 'Today':
return 'new Date().toISOString()' return 'new Date().toISOString()'
@ -89,7 +92,10 @@ const getExpressionToEvaluate =
return 'Math.random().toString(36).substring(2, 15)' return 'Math.random().toString(36).substring(2, 15)'
} }
case 'User ID': { case 'User ID': {
return resultId ?? 'Math.random().toString(36).substring(2, 15)' return (
state.typebotsQueue[0].resultId ??
'Math.random().toString(36).substring(2, 15)'
)
} }
case 'Map item with same index': { case 'Map item with same index': {
return `const itemIndex = ${options.mapListItemParams?.baseListVariableId}.indexOf(${options.mapListItemParams?.baseItemVariableId}) return `const itemIndex = ${options.mapListItemParams?.baseListVariableId}.indexOf(${options.mapListItemParams?.baseItemVariableId})

View File

@ -1,36 +1,14 @@
import { publicProcedure } from '@/helpers/server/trpc' import { publicProcedure } from '@/helpers/server/trpc'
import { TRPCError } from '@trpc/server'
import {
ChatReply,
chatReplySchema,
GoogleAnalyticsBlock,
IntegrationBlockType,
PixelBlock,
ReplyLog,
sendMessageInputSchema,
SessionState,
StartParams,
StartTypebot,
startTypebotSchema,
Theme,
Variable,
VariableWithValue,
} from '@typebot.io/schemas'
import { isDefined, isNotEmpty, omit } from '@typebot.io/lib'
import { prefillVariables } from '@/features/variables/prefillVariables'
import { injectVariablesFromExistingResult } from '@/features/variables/injectVariablesFromExistingResult'
import { deepParseVariables } from '@/features/variables/deepParseVariable'
import { parseVariables } from '@/features/variables/parseVariables'
import { NodeType, parse } from 'node-html-parser'
import { saveStateToDatabase } from '../helpers/saveStateToDatabase' import { saveStateToDatabase } from '../helpers/saveStateToDatabase'
import { getSession } from '../queries/getSession' import { getSession } from '../queries/getSession'
import { continueBotFlow } from '../helpers/continueBotFlow' import { continueBotFlow } from '../helpers/continueBotFlow'
import { startBotFlow } from '../helpers/startBotFlow' import { parseDynamicTheme } from '../helpers/parseDynamicTheme'
import { findTypebot } from '../queries/findTypebot' import { startSession } from '../helpers/startSession'
import { findPublicTypebot } from '../queries/findPublicTypebot' import { restartSession } from '../queries/restartSession'
import { findResult } from '../queries/findResult' import {
import { createId } from '@paralleldrive/cuid2' chatReplySchema,
import { env } from '@typebot.io/env' sendMessageInputSchema,
} from '@typebot.io/schemas/features/chat/schema'
export const sendMessage = publicProcedure export const sendMessage = publicProcedure
.meta({ .meta({
@ -53,7 +31,6 @@ export const sendMessage = publicProcedure
if (!session) { if (!session) {
const { const {
sessionId,
typebot, typebot,
messages, messages,
input, input,
@ -61,9 +38,27 @@ export const sendMessage = publicProcedure
dynamicTheme, dynamicTheme,
logs, logs,
clientSideActions, clientSideActions,
} = await startSession(startParams, user?.id, clientLogs) newSessionState,
} = await startSession(startParams, user?.id)
const allLogs = clientLogs ? [...(logs ?? []), ...clientLogs] : logs
const session = startParams?.isOnlyRegistering
? await restartSession({
state: newSessionState,
})
: await saveStateToDatabase({
isFirstSave: true,
session: {
state: newSessionState,
},
input,
logs: allLogs,
clientSideActions,
})
return { return {
sessionId, sessionId: session.id,
typebot: typebot typebot: typebot
? { ? {
id: typebot.id, id: typebot.id,
@ -105,349 +100,10 @@ export const sendMessage = publicProcedure
messages, messages,
input, input,
clientSideActions, clientSideActions,
dynamicTheme: parseDynamicThemeReply(newSessionState), dynamicTheme: parseDynamicTheme(newSessionState),
logs, logs,
lastMessageNewFormat, lastMessageNewFormat,
} }
} }
} }
) )
const startSession = async (
startParams?: StartParams,
userId?: string,
clientLogs?: ReplyLog[]
) => {
if (!startParams)
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'StartParams are missing',
})
const typebot = await getTypebot(startParams, userId)
const prefilledVariables = startParams.prefilledVariables
? prefillVariables(typebot.variables, startParams.prefilledVariables)
: typebot.variables
const result = await getResult({
...startParams,
isPreview: startParams.isPreview || typeof startParams.typebot !== 'string',
typebotId: typebot.id,
prefilledVariables,
isRememberUserEnabled:
typebot.settings.general.rememberUser?.isEnabled ??
(isDefined(typebot.settings.general.isNewResultOnRefreshEnabled)
? !typebot.settings.general.isNewResultOnRefreshEnabled
: false),
})
const startVariables =
result && result.variables.length > 0
? injectVariablesFromExistingResult(prefilledVariables, result.variables)
: prefilledVariables
const initialState: SessionState = {
version: '2',
typebotsQueue: [
{
resultId: result?.id,
typebot: {
version: typebot.version,
id: typebot.id,
groups: typebot.groups,
edges: typebot.edges,
variables: startVariables,
},
answers: [],
},
],
dynamicTheme: parseDynamicThemeInState(typebot.theme),
isStreamEnabled: startParams.isStreamEnabled,
}
const { messages, input, clientSideActions, newSessionState, logs } =
await startBotFlow(initialState, startParams.startGroupId)
const clientSideActionsNeedSessionId = clientSideActions?.some(
(action) =>
'setVariable' in action || 'streamOpenAiChatCompletion' in action
)
const startClientSideAction = clientSideActions ?? []
const parsedStartPropsActions = parseStartClientSideAction(typebot)
const startLogs = logs ?? []
if (isDefined(parsedStartPropsActions)) {
if (!result) {
if ('startPropsToInject' in parsedStartPropsActions) {
const { customHeadCode, googleAnalyticsId, pixelId, gtmId } =
parsedStartPropsActions.startPropsToInject
let toolsList = ''
if (customHeadCode) toolsList += 'Custom head code, '
if (googleAnalyticsId) toolsList += 'Google Analytics, '
if (pixelId) toolsList += 'Pixel, '
if (gtmId) toolsList += 'Google Tag Manager, '
toolsList = toolsList.slice(0, -2)
startLogs.push({
description: `${toolsList} ${
toolsList.includes(',') ? 'are not' : 'is not'
} enabled in Preview mode`,
status: 'info',
})
}
} else {
startClientSideAction.push(parsedStartPropsActions)
}
}
if (!input && !clientSideActionsNeedSessionId)
return {
messages,
clientSideActions:
startClientSideAction.length > 0 ? startClientSideAction : undefined,
typebot: {
id: typebot.id,
settings: deepParseVariables(
newSessionState.typebotsQueue[0].typebot.variables
)(typebot.settings),
theme: deepParseVariables(
newSessionState.typebotsQueue[0].typebot.variables
)(typebot.theme),
},
dynamicTheme: parseDynamicThemeReply(newSessionState),
logs: startLogs.length > 0 ? startLogs : undefined,
}
const allLogs = clientLogs ? [...(logs ?? []), ...clientLogs] : logs
const session = await saveStateToDatabase({
session: {
state: newSessionState,
},
input,
logs: allLogs,
clientSideActions,
})
return {
resultId: result?.id,
sessionId: session.id,
typebot: {
id: typebot.id,
settings: deepParseVariables(
newSessionState.typebotsQueue[0].typebot.variables
)(typebot.settings),
theme: deepParseVariables(
newSessionState.typebotsQueue[0].typebot.variables
)(typebot.theme),
},
messages,
input,
clientSideActions:
startClientSideAction.length > 0 ? startClientSideAction : undefined,
dynamicTheme: parseDynamicThemeReply(newSessionState),
logs: startLogs.length > 0 ? startLogs : undefined,
} satisfies ChatReply
}
const getTypebot = async (
{ typebot, isPreview }: Pick<StartParams, 'typebot' | 'isPreview'>,
userId?: string
): Promise<StartTypebot> => {
if (typeof typebot !== 'string') return typebot
if (isPreview && !userId && !env.NEXT_PUBLIC_E2E_TEST)
throw new TRPCError({
code: 'UNAUTHORIZED',
message:
'You need to authenticate the request to start a bot in preview mode.',
})
const typebotQuery = isPreview
? await findTypebot({ id: typebot, userId })
: await findPublicTypebot({ publicId: typebot })
const parsedTypebot =
typebotQuery && 'typebot' in typebotQuery
? {
id: typebotQuery.typebotId,
...omit(typebotQuery.typebot, 'workspace'),
...omit(typebotQuery, 'typebot', 'typebotId'),
}
: typebotQuery
if (!parsedTypebot || parsedTypebot.isArchived)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Typebot not found',
})
const isQuarantinedOrSuspended =
typebotQuery &&
'typebot' in typebotQuery &&
(typebotQuery.typebot.workspace.isQuarantined ||
typebotQuery.typebot.workspace.isSuspended)
if (
('isClosed' in parsedTypebot && parsedTypebot.isClosed) ||
isQuarantinedOrSuspended
)
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Typebot is closed',
})
return startTypebotSchema.parse(parsedTypebot)
}
const getResult = async ({
isPreview,
resultId,
prefilledVariables,
isRememberUserEnabled,
}: Pick<StartParams, 'isPreview' | 'resultId'> & {
typebotId: string
prefilledVariables: Variable[]
isRememberUserEnabled: boolean
}) => {
if (isPreview) return
const existingResult =
resultId && isRememberUserEnabled
? await findResult({ id: resultId })
: undefined
const prefilledVariableWithValue = prefilledVariables.filter(
(prefilledVariable) => isDefined(prefilledVariable.value)
)
const updatedResult = {
variables: prefilledVariableWithValue.concat(
existingResult?.variables.filter(
(resultVariable) =>
isDefined(resultVariable.value) &&
!prefilledVariableWithValue.some(
(prefilledVariable) =>
prefilledVariable.name === resultVariable.name
)
) ?? []
) as VariableWithValue[],
}
return {
id: existingResult?.id ?? createId(),
variables: updatedResult.variables,
answers: existingResult?.answers ?? [],
}
}
const parseDynamicThemeInState = (theme: Theme) => {
const hostAvatarUrl =
theme.chat.hostAvatar?.isEnabled ?? true
? theme.chat.hostAvatar?.url
: undefined
const guestAvatarUrl =
theme.chat.guestAvatar?.isEnabled ?? false
? theme.chat.guestAvatar?.url
: undefined
if (!hostAvatarUrl?.startsWith('{{') && !guestAvatarUrl?.startsWith('{{'))
return
return {
hostAvatarUrl: hostAvatarUrl?.startsWith('{{') ? hostAvatarUrl : undefined,
guestAvatarUrl: guestAvatarUrl?.startsWith('{{')
? guestAvatarUrl
: undefined,
}
}
const parseDynamicThemeReply = (
state: SessionState | undefined
): ChatReply['dynamicTheme'] => {
if (!state?.dynamicTheme) return
return {
hostAvatarUrl: parseVariables(state.typebotsQueue[0].typebot.variables)(
state.dynamicTheme.hostAvatarUrl
),
guestAvatarUrl: parseVariables(state.typebotsQueue[0].typebot.variables)(
state.dynamicTheme.guestAvatarUrl
),
}
}
const parseStartClientSideAction = (
typebot: StartTypebot
): NonNullable<ChatReply['clientSideActions']>[number] | undefined => {
const blocks = typebot.groups.flatMap((group) => group.blocks)
const startPropsToInject = {
customHeadCode: isNotEmpty(typebot.settings.metadata.customHeadCode)
? parseHeadCode(typebot.settings.metadata.customHeadCode)
: undefined,
gtmId: typebot.settings.metadata.googleTagManagerId,
googleAnalyticsId: (
blocks.find(
(block) =>
block.type === IntegrationBlockType.GOOGLE_ANALYTICS &&
block.options.trackingId
) as GoogleAnalyticsBlock | undefined
)?.options.trackingId,
pixelId: (
blocks.find(
(block) =>
block.type === IntegrationBlockType.PIXEL &&
block.options.pixelId &&
block.options.isInitSkip !== true
) as PixelBlock | undefined
)?.options.pixelId,
}
if (
!startPropsToInject.customHeadCode &&
!startPropsToInject.gtmId &&
!startPropsToInject.googleAnalyticsId &&
!startPropsToInject.pixelId
)
return
return {
startPropsToInject,
}
}
const parseHeadCode = (code: string) => {
code = injectTryCatch(code)
return parse(code)
.childNodes.filter((child) => child.nodeType !== NodeType.TEXT_NODE)
.join('\n')
}
const injectTryCatch = (headCode: string) => {
const scriptTagRegex = /<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi
const scriptTags = headCode.match(scriptTagRegex)
if (scriptTags) {
scriptTags.forEach(function (tag) {
const wrappedTag = tag.replace(
/(<script\b[^>]*>)([\s\S]*?)(<\/script>)/gi,
function (_, openingTag, content, closingTag) {
if (!isValidJsSyntax(content)) return ''
return `${openingTag}
try {
${content}
} catch (e) {
console.warn(e);
}
${closingTag}`
}
)
headCode = headCode.replace(tag, wrappedTag)
})
}
return headCode
}
const isValidJsSyntax = (snippet: string): boolean => {
try {
new Function(snippet)
return true
} catch (err) {
return false
}
}

View File

@ -1,8 +1,6 @@
import { TRPCError } from '@trpc/server' import { TRPCError } from '@trpc/server'
import { import {
AnswerInSessionState, AnswerInSessionState,
Block,
BlockType,
BubbleBlockType, BubbleBlockType,
ChatReply, ChatReply,
InputBlock, InputBlock,
@ -16,11 +14,10 @@ import {
invalidEmailDefaultRetryMessage, invalidEmailDefaultRetryMessage,
} from '@typebot.io/schemas' } from '@typebot.io/schemas'
import { isInputBlock, byId } from '@typebot.io/lib' import { isInputBlock, byId } from '@typebot.io/lib'
import { executeGroup } from './executeGroup' import { executeGroup, parseInput } from './executeGroup'
import { getNextGroup } from './getNextGroup' import { getNextGroup } from './getNextGroup'
import { validateEmail } from '@/features/blocks/inputs/email/validateEmail' import { validateEmail } from '@/features/blocks/inputs/email/validateEmail'
import { formatPhoneNumber } from '@/features/blocks/inputs/phone/formatPhoneNumber' import { formatPhoneNumber } from '@/features/blocks/inputs/phone/formatPhoneNumber'
import { validatePhoneNumber } from '@/features/blocks/inputs/phone/validatePhoneNumber'
import { validateUrl } from '@/features/blocks/inputs/url/validateUrl' import { validateUrl } from '@/features/blocks/inputs/url/validateUrl'
import { updateVariables } from '@/features/variables/updateVariables' import { updateVariables } from '@/features/variables/updateVariables'
import { parseVariables } from '@/features/variables/parseVariables' import { parseVariables } from '@/features/variables/parseVariables'
@ -28,6 +25,13 @@ import { OpenAIBlock } from '@typebot.io/schemas/features/blocks/integrations/op
import { resumeChatCompletion } from '@/features/blocks/integrations/openai/resumeChatCompletion' import { resumeChatCompletion } from '@/features/blocks/integrations/openai/resumeChatCompletion'
import { resumeWebhookExecution } from '@/features/blocks/integrations/webhook/resumeWebhookExecution' import { resumeWebhookExecution } from '@/features/blocks/integrations/webhook/resumeWebhookExecution'
import { upsertAnswer } from '../queries/upsertAnswer' import { upsertAnswer } from '../queries/upsertAnswer'
import { startBotFlow } from './startBotFlow'
import { parseButtonsReply } from '@/features/blocks/inputs/buttons/parseButtonsReply'
import { ParsedReply } from '../types'
import { validateNumber } from '@/features/blocks/inputs/number/validateNumber'
import { parseDateReply } from '@/features/blocks/inputs/date/parseDateReply'
import { validateRatingReply } from '@/features/blocks/inputs/rating/validateRatingReply'
import { parsePictureChoicesReply } from '@/features/blocks/inputs/pictureChoice/parsePictureChoicesReply'
export const continueBotFlow = export const continueBotFlow =
(state: SessionState) => (state: SessionState) =>
@ -45,11 +49,7 @@ export const continueBotFlow =
const block = blockIndex >= 0 ? group?.blocks[blockIndex ?? 0] : null const block = blockIndex >= 0 ? group?.blocks[blockIndex ?? 0] : null
if (!block || !group) if (!block || !group) return startBotFlow(state)
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Current block not found',
})
if (block.type === LogicBlockType.SET_VARIABLE) { if (block.type === LogicBlockType.SET_VARIABLE) {
const existingVariable = state.typebotsQueue[0].typebot.variables.find( const existingVariable = state.typebotsQueue[0].typebot.variables.find(
@ -89,15 +89,15 @@ export const continueBotFlow =
let formattedReply: string | undefined let formattedReply: string | undefined
if (isInputBlock(block)) { if (isInputBlock(block)) {
if (reply && !isReplyValid(reply, block)) const parseResult = parseReply(newSessionState)(reply, block)
return { ...parseRetryMessage(block), newSessionState }
formattedReply = formatReply(reply, block.type) if (parseResult.status === 'fail')
return {
if (!formattedReply && !canSkip(block.type)) { ...(await parseRetryMessage(newSessionState)(block)),
return { ...parseRetryMessage(block), newSessionState } newSessionState,
} }
formattedReply = 'reply' in parseResult ? parseResult.reply : undefined
const nextEdgeId = getOutgoingEdgeId(newSessionState)( const nextEdgeId = getOutgoingEdgeId(newSessionState)(
block, block,
formattedReply formattedReply
@ -188,26 +188,27 @@ const saveVariableValueIfAny =
return newSessionState return newSessionState
} }
const parseRetryMessage = ( const parseRetryMessage =
block: InputBlock (state: SessionState) =>
): Pick<ChatReply, 'messages' | 'input'> => { async (block: InputBlock): Promise<Pick<ChatReply, 'messages' | 'input'>> => {
const retryMessage = const retryMessage =
'retryMessageContent' in block.options && block.options.retryMessageContent 'retryMessageContent' in block.options &&
? block.options.retryMessageContent block.options.retryMessageContent
: parseDefaultRetryMessage(block) ? block.options.retryMessageContent
return { : parseDefaultRetryMessage(block)
messages: [ return {
{ messages: [
id: block.id, {
type: BubbleBlockType.TEXT, id: block.id,
content: { type: BubbleBlockType.TEXT,
richText: [{ type: 'p', children: [{ text: retryMessage }] }], content: {
richText: [{ type: 'p', children: [{ text: retryMessage }] }],
},
}, },
}, ],
], input: await parseInput(state)(block),
input: block, }
} }
}
const parseDefaultRetryMessage = (block: InputBlock): string => { const parseDefaultRetryMessage = (block: InputBlock): string => {
switch (block.type) { switch (block.type) {
@ -306,34 +307,61 @@ const getOutgoingEdgeId =
return block.outgoingEdgeId return block.outgoingEdgeId
} }
export const formatReply = ( const parseReply =
inputValue: string | undefined, (state: SessionState) =>
blockType: BlockType (inputValue: string | undefined, block: InputBlock): ParsedReply => {
): string | undefined => { if (!inputValue) return { status: 'fail' }
if (!inputValue) return switch (block.type) {
switch (blockType) { case InputBlockType.EMAIL: {
case InputBlockType.PHONE: const isValid = validateEmail(inputValue)
return formatPhoneNumber(inputValue) if (!isValid) return { status: 'fail' }
return { status: 'success', reply: inputValue }
}
case InputBlockType.PHONE: {
const formattedPhone = formatPhoneNumber(
inputValue,
block.options.defaultCountryCode
)
if (!formattedPhone) return { status: 'fail' }
return { status: 'success', reply: formattedPhone }
}
case InputBlockType.URL: {
const isValid = validateUrl(inputValue)
if (!isValid) return { status: 'fail' }
return { status: 'success', reply: inputValue }
}
case InputBlockType.CHOICE: {
return parseButtonsReply(state)(inputValue, block)
}
case InputBlockType.NUMBER: {
const isValid = validateNumber(inputValue)
if (!isValid) return { status: 'fail' }
return { status: 'success', reply: inputValue }
}
case InputBlockType.DATE: {
return parseDateReply(inputValue, block)
}
case InputBlockType.FILE: {
if (!inputValue) return { status: 'skip' }
return { status: 'success', reply: inputValue }
}
case InputBlockType.PAYMENT: {
if (inputValue === 'fail') return { status: 'fail' }
return { status: 'success', reply: inputValue }
}
case InputBlockType.RATING: {
const isValid = validateRatingReply(inputValue, block)
if (!isValid) return { status: 'fail' }
return { status: 'success', reply: inputValue }
}
case InputBlockType.PICTURE_CHOICE: {
return parsePictureChoicesReply(state)(inputValue, block)
}
case InputBlockType.TEXT: {
return { status: 'success', reply: inputValue }
}
}
} }
return inputValue
}
export const isReplyValid = (inputValue: string, block: Block): boolean => {
switch (block.type) {
case InputBlockType.EMAIL:
return validateEmail(inputValue)
case InputBlockType.PHONE:
return validatePhoneNumber(inputValue)
case InputBlockType.URL:
return validateUrl(inputValue)
case InputBlockType.PAYMENT:
return inputValue !== 'fail'
}
return true
}
export const canSkip = (inputType: InputBlockType) =>
inputType === InputBlockType.FILE
export const safeJsonParse = (value: string): unknown => { export const safeJsonParse = (value: string): unknown => {
try { try {

View File

@ -193,7 +193,7 @@ const parseBubbleBlock =
} }
} }
const parseInput = export const parseInput =
(state: SessionState) => (state: SessionState) =>
async (block: InputBlock): Promise<ChatReply['input']> => { async (block: InputBlock): Promise<ChatReply['input']> => {
switch (block.type) { switch (block.type) {

View File

@ -0,0 +1,16 @@
import { parseVariables } from '@/features/variables/parseVariables'
import { SessionState, ChatReply } from '@typebot.io/schemas'
export const parseDynamicTheme = (
state: SessionState | undefined
): ChatReply['dynamicTheme'] => {
if (!state?.dynamicTheme) return
return {
hostAvatarUrl: parseVariables(state?.typebotsQueue[0].typebot.variables)(
state.dynamicTheme.hostAvatarUrl
),
guestAvatarUrl: parseVariables(state?.typebotsQueue[0].typebot.variables)(
state.dynamicTheme.guestAvatarUrl
),
}
}

View File

@ -4,8 +4,10 @@ import { saveLogs } from '../queries/saveLogs'
import { updateSession } from '../queries/updateSession' import { updateSession } from '../queries/updateSession'
import { formatLogDetails } from '@/features/logs/helpers/formatLogDetails' import { formatLogDetails } from '@/features/logs/helpers/formatLogDetails'
import { createSession } from '../queries/createSession' import { createSession } from '../queries/createSession'
import { deleteSession } from '../queries/deleteSession'
type Props = { type Props = {
isFirstSave?: boolean
session: Pick<ChatSession, 'state'> & { id?: string } session: Pick<ChatSession, 'state'> & { id?: string }
input: ChatReply['input'] input: ChatReply['input']
logs: ChatReply['logs'] logs: ChatReply['logs']
@ -13,23 +15,30 @@ type Props = {
} }
export const saveStateToDatabase = async ({ export const saveStateToDatabase = async ({
isFirstSave,
session: { state, id }, session: { state, id },
input, input,
logs, logs,
clientSideActions, clientSideActions,
}: Props) => { }: Props) => {
if (id) await updateSession({ id, state })
const session = id ? { state, id } : await createSession({ state })
const resultId = state.typebotsQueue[0].resultId
if (!resultId) return session
const containsSetVariableClientSideAction = clientSideActions?.some( const containsSetVariableClientSideAction = clientSideActions?.some(
(action) => 'setVariable' in action (action) => 'setVariable' in action
) )
const isCompleted = Boolean(!input && !containsSetVariableClientSideAction)
const resultId = state.typebotsQueue[0].resultId
if (id) {
if (isCompleted && resultId) await deleteSession(id)
else await updateSession({ id, state })
}
const session =
id && !isFirstSave ? { state, id } : await createSession({ id, state })
if (!resultId) return session
const answers = state.typebotsQueue[0].answers const answers = state.typebotsQueue[0].answers
await upsertResult({ await upsertResult({

View File

@ -0,0 +1,360 @@
import { deepParseVariables } from '@/features/variables/deepParseVariable'
import { injectVariablesFromExistingResult } from '@/features/variables/injectVariablesFromExistingResult'
import { prefillVariables } from '@/features/variables/prefillVariables'
import { createId } from '@paralleldrive/cuid2'
import { TRPCError } from '@trpc/server'
import { isDefined, omit, isNotEmpty } from '@typebot.io/lib'
import {
Variable,
VariableWithValue,
Theme,
IntegrationBlockType,
GoogleAnalyticsBlock,
PixelBlock,
SessionState,
} from '@typebot.io/schemas'
import {
ChatReply,
StartParams,
StartTypebot,
startTypebotSchema,
} from '@typebot.io/schemas/features/chat/schema'
import { findPublicTypebot } from '../queries/findPublicTypebot'
import { findResult } from '../queries/findResult'
import { findTypebot } from '../queries/findTypebot'
import { startBotFlow } from './startBotFlow'
import parse, { NodeType } from 'node-html-parser'
import { parseDynamicTheme } from './parseDynamicTheme'
import { env } from '@typebot.io/env'
export const startSession = async (
startParams?: StartParams,
userId?: string
): Promise<ChatReply & { newSessionState: SessionState }> => {
if (!startParams)
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'StartParams are missing',
})
const typebot = await getTypebot(startParams, userId)
const prefilledVariables = startParams.prefilledVariables
? prefillVariables(typebot.variables, startParams.prefilledVariables)
: typebot.variables
const result = await getResult({
...startParams,
isPreview: startParams.isPreview || typeof startParams.typebot !== 'string',
typebotId: typebot.id,
prefilledVariables,
isRememberUserEnabled:
typebot.settings.general.rememberUser?.isEnabled ??
(isDefined(typebot.settings.general.isNewResultOnRefreshEnabled)
? !typebot.settings.general.isNewResultOnRefreshEnabled
: false),
})
const startVariables =
result && result.variables.length > 0
? injectVariablesFromExistingResult(prefilledVariables, result.variables)
: prefilledVariables
const initialState: SessionState = {
version: '2',
typebotsQueue: [
{
resultId: result?.id,
typebot: {
version: typebot.version,
id: typebot.id,
groups: typebot.groups,
edges: typebot.edges,
variables: startVariables,
},
answers: [],
},
],
dynamicTheme: parseDynamicThemeInState(typebot.theme),
isStreamEnabled: startParams.isStreamEnabled,
typingEmulation: typebot.settings.typingEmulation,
}
if (startParams.isOnlyRegistering) {
return {
newSessionState: initialState,
typebot: {
id: typebot.id,
settings: deepParseVariables(
initialState.typebotsQueue[0].typebot.variables
)(typebot.settings),
theme: deepParseVariables(
initialState.typebotsQueue[0].typebot.variables
)(typebot.theme),
},
dynamicTheme: parseDynamicTheme(initialState),
messages: [],
}
}
const { messages, input, clientSideActions, newSessionState, logs } =
await startBotFlow(initialState, startParams.startGroupId)
const clientSideActionsNeedSessionId = clientSideActions?.some(
(action) =>
'setVariable' in action || 'streamOpenAiChatCompletion' in action
)
const startClientSideAction = clientSideActions ?? []
const parsedStartPropsActions = parseStartClientSideAction(typebot)
const startLogs = logs ?? []
if (isDefined(parsedStartPropsActions)) {
if (!result) {
if ('startPropsToInject' in parsedStartPropsActions) {
const { customHeadCode, googleAnalyticsId, pixelId, gtmId } =
parsedStartPropsActions.startPropsToInject
let toolsList = ''
if (customHeadCode) toolsList += 'Custom head code, '
if (googleAnalyticsId) toolsList += 'Google Analytics, '
if (pixelId) toolsList += 'Pixel, '
if (gtmId) toolsList += 'Google Tag Manager, '
toolsList = toolsList.slice(0, -2)
startLogs.push({
description: `${toolsList} ${
toolsList.includes(',') ? 'are not' : 'is not'
} enabled in Preview mode`,
status: 'info',
})
}
} else {
startClientSideAction.push(parsedStartPropsActions)
}
}
if (!input && !clientSideActionsNeedSessionId)
return {
newSessionState,
messages,
clientSideActions:
startClientSideAction.length > 0 ? startClientSideAction : undefined,
typebot: {
id: typebot.id,
settings: deepParseVariables(
newSessionState.typebotsQueue[0].typebot.variables
)(typebot.settings),
theme: deepParseVariables(
newSessionState.typebotsQueue[0].typebot.variables
)(typebot.theme),
},
dynamicTheme: parseDynamicTheme(newSessionState),
logs: startLogs.length > 0 ? startLogs : undefined,
}
return {
newSessionState,
resultId: result?.id,
typebot: {
id: typebot.id,
settings: deepParseVariables(
newSessionState.typebotsQueue[0].typebot.variables
)(typebot.settings),
theme: deepParseVariables(
newSessionState.typebotsQueue[0].typebot.variables
)(typebot.theme),
},
messages,
input,
clientSideActions:
startClientSideAction.length > 0 ? startClientSideAction : undefined,
dynamicTheme: parseDynamicTheme(newSessionState),
logs: startLogs.length > 0 ? startLogs : undefined,
}
}
const getTypebot = async (
{ typebot, isPreview }: Pick<StartParams, 'typebot' | 'isPreview'>,
userId?: string
): Promise<StartTypebot> => {
if (typeof typebot !== 'string') return typebot
if (isPreview && !userId && !env.NEXT_PUBLIC_E2E_TEST)
throw new TRPCError({
code: 'UNAUTHORIZED',
message:
'You need to authenticate the request to start a bot in preview mode.',
})
const typebotQuery = isPreview
? await findTypebot({ id: typebot, userId })
: await findPublicTypebot({ publicId: typebot })
const parsedTypebot =
typebotQuery && 'typebot' in typebotQuery
? {
id: typebotQuery.typebotId,
...omit(typebotQuery.typebot, 'workspace'),
...omit(typebotQuery, 'typebot', 'typebotId'),
}
: typebotQuery
if (!parsedTypebot || parsedTypebot.isArchived)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Typebot not found',
})
const isQuarantinedOrSuspended =
typebotQuery &&
'typebot' in typebotQuery &&
(typebotQuery.typebot.workspace.isQuarantined ||
typebotQuery.typebot.workspace.isSuspended)
if (
('isClosed' in parsedTypebot && parsedTypebot.isClosed) ||
isQuarantinedOrSuspended
)
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Typebot is closed',
})
return startTypebotSchema.parse(parsedTypebot)
}
const getResult = async ({
isPreview,
resultId,
prefilledVariables,
isRememberUserEnabled,
}: Pick<StartParams, 'isPreview' | 'resultId'> & {
typebotId: string
prefilledVariables: Variable[]
isRememberUserEnabled: boolean
}) => {
if (isPreview) return
const existingResult =
resultId && isRememberUserEnabled
? await findResult({ id: resultId })
: undefined
const prefilledVariableWithValue = prefilledVariables.filter(
(prefilledVariable) => isDefined(prefilledVariable.value)
)
const updatedResult = {
variables: prefilledVariableWithValue.concat(
existingResult?.variables.filter(
(resultVariable) =>
isDefined(resultVariable.value) &&
!prefilledVariableWithValue.some(
(prefilledVariable) =>
prefilledVariable.name === resultVariable.name
)
) ?? []
) as VariableWithValue[],
}
return {
id: existingResult?.id ?? createId(),
variables: updatedResult.variables,
answers: existingResult?.answers ?? [],
}
}
const parseDynamicThemeInState = (theme: Theme) => {
const hostAvatarUrl =
theme.chat.hostAvatar?.isEnabled ?? true
? theme.chat.hostAvatar?.url
: undefined
const guestAvatarUrl =
theme.chat.guestAvatar?.isEnabled ?? false
? theme.chat.guestAvatar?.url
: undefined
if (!hostAvatarUrl?.startsWith('{{') && !guestAvatarUrl?.startsWith('{{'))
return
return {
hostAvatarUrl: hostAvatarUrl?.startsWith('{{') ? hostAvatarUrl : undefined,
guestAvatarUrl: guestAvatarUrl?.startsWith('{{')
? guestAvatarUrl
: undefined,
}
}
const parseStartClientSideAction = (
typebot: StartTypebot
): NonNullable<ChatReply['clientSideActions']>[number] | undefined => {
const blocks = typebot.groups.flatMap((group) => group.blocks)
const startPropsToInject = {
customHeadCode: isNotEmpty(typebot.settings.metadata.customHeadCode)
? parseHeadCode(typebot.settings.metadata.customHeadCode)
: undefined,
gtmId: typebot.settings.metadata.googleTagManagerId,
googleAnalyticsId: (
blocks.find(
(block) =>
block.type === IntegrationBlockType.GOOGLE_ANALYTICS &&
block.options.trackingId
) as GoogleAnalyticsBlock | undefined
)?.options.trackingId,
pixelId: (
blocks.find(
(block) =>
block.type === IntegrationBlockType.PIXEL &&
block.options.pixelId &&
block.options.isInitSkip !== true
) as PixelBlock | undefined
)?.options.pixelId,
}
if (
!startPropsToInject.customHeadCode &&
!startPropsToInject.gtmId &&
!startPropsToInject.googleAnalyticsId &&
!startPropsToInject.pixelId
)
return
return {
startPropsToInject,
}
}
const parseHeadCode = (code: string) => {
code = injectTryCatch(code)
return parse(code)
.childNodes.filter((child) => child.nodeType !== NodeType.TEXT_NODE)
.join('\n')
}
const injectTryCatch = (headCode: string) => {
const scriptTagRegex = /<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi
const scriptTags = headCode.match(scriptTagRegex)
if (scriptTags) {
scriptTags.forEach(function (tag) {
const wrappedTag = tag.replace(
/(<script\b[^>]*>)([\s\S]*?)(<\/script>)/gi,
function (_, openingTag, content, closingTag) {
if (!isValidJsSyntax(content)) return ''
return `${openingTag}
try {
${content}
} catch (e) {
console.warn(e);
}
${closingTag}`
}
)
headCode = headCode.replace(tag, wrappedTag)
})
}
return headCode
}
const isValidJsSyntax = (snippet: string): boolean => {
try {
new Function(snippet)
return true
} catch (err) {
return false
}
}

View File

@ -2,12 +2,14 @@ import prisma from '@/lib/prisma'
import { SessionState } from '@typebot.io/schemas' import { SessionState } from '@typebot.io/schemas'
type Props = { type Props = {
id?: string
state: SessionState state: SessionState
} }
export const createSession = async ({ state }: Props) => export const createSession = async ({ id, state }: Props) =>
prisma.chatSession.create({ prisma.chatSession.create({
data: { data: {
id,
state, state,
}, },
}) })

View File

@ -0,0 +1,8 @@
import prisma from '@/lib/prisma'
export const deleteSession = (id: string) =>
prisma.chatSession.deleteMany({
where: {
id,
},
})

View File

@ -0,0 +1,24 @@
import prisma from '@/lib/prisma'
import { SessionState } from '@typebot.io/schemas'
type Props = {
id?: string
state: SessionState
}
export const restartSession = async ({ id, state }: Props) => {
if (id) {
await prisma.chatSession.deleteMany({
where: {
id,
},
})
}
return prisma.chatSession.create({
data: {
id,
state,
},
})
}

View File

@ -11,3 +11,8 @@ export type ExecuteIntegrationResponse = {
outgoingEdgeId: EdgeId | undefined outgoingEdgeId: EdgeId | undefined
newSessionState?: SessionState newSessionState?: SessionState
} & Pick<ChatReply, 'clientSideActions' | 'logs'> } & Pick<ChatReply, 'clientSideActions' | 'logs'>
export type ParsedReply =
| { status: 'success'; reply: string }
| { status: 'fail' }
| { status: 'skip' }

View File

@ -0,0 +1,42 @@
import { publicProcedure } from '@/helpers/server/trpc'
import { whatsAppWebhookRequestBodySchema } from '@typebot.io/schemas/features/whatsapp'
import { resumeWhatsAppFlow } from '../helpers/resumeWhatsAppFlow'
import { z } from 'zod'
import { isNotDefined } from '@typebot.io/lib'
export const receiveMessage = publicProcedure
.meta({
openapi: {
method: 'POST',
path: '/workspaces/{workspaceId}/whatsapp/phoneNumbers/{phoneNumberId}/webhook',
summary: 'Receive WhatsApp Message',
},
})
.input(
z
.object({ workspaceId: z.string(), phoneNumberId: z.string() })
.merge(whatsAppWebhookRequestBodySchema)
)
.output(
z.object({
message: z.string(),
})
)
.mutation(async ({ input: { entry, workspaceId, phoneNumberId } }) => {
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-${phoneNumberId}-${receivedMessage.from}`,
phoneNumberId,
workspaceId,
contact: {
name: contactName,
phoneNumber: contactPhoneNumber,
},
})
})

View File

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

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

@ -0,0 +1,138 @@
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({
isOnlyRegistering: !canSendDirectMessagesToUser,
typebot: typebotId,
isPreview: true,
startGroupId,
})
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

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

@ -0,0 +1,45 @@
import { publicProcedure } from '@/helpers/server/trpc'
import prisma from '@/lib/prisma'
import { TRPCError } from '@trpc/server'
import { z } from 'zod'
export const subscribeWebhook = publicProcedure
.meta({
openapi: {
method: 'GET',
path: '/workspaces/{workspaceId}/whatsapp/phoneNumbers/{phoneNumberId}/webhook',
summary: 'Subscribe WhatsApp webhook',
protect: true,
},
})
.input(
z.object({
workspaceId: z.string(),
phoneNumberId: z.string(),
'hub.challenge': z.string(),
'hub.verify_token': z.string(),
})
)
.output(z.number())
.query(
async ({
input: { 'hub.challenge': challenge, 'hub.verify_token': token },
}) => {
const verificationToken = await prisma.verificationToken.findUnique({
where: {
token,
},
})
if (!verificationToken)
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'Unauthorized',
})
await prisma.verificationToken.delete({
where: {
token,
},
})
return Number(challenge)
}
)

View File

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

@ -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 '@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')
}

Some files were not shown because too many files have changed in this diff Show More