@ -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">
|
||||||
|
@ -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 = {
|
||||||
|
@ -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"
|
||||||
|
BIN
apps/builder/public/images/meta-system-user-assets.png
Normal file
BIN
apps/builder/public/images/meta-system-user-assets.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 242 KiB |
BIN
apps/builder/public/images/whatsapp-phone-selection.png
Normal file
BIN
apps/builder/public/images/whatsapp-phone-selection.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 94 KiB |
BIN
apps/builder/public/images/whatsapp-quickstart-page.png
Normal file
BIN
apps/builder/public/images/whatsapp-quickstart-page.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 494 KiB |
@ -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)
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -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 && (
|
||||||
|
@ -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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
60
apps/builder/src/components/logos/WhatsAppLogo.tsx
Normal file
60
apps/builder/src/components/logos/WhatsAppLogo.tsx
Normal 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>
|
||||||
|
)
|
@ -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(() => {
|
||||||
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
@ -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)}
|
||||||
/>
|
/>
|
||||||
|
@ -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>
|
||||||
|
@ -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 ?? '')
|
||||||
|
@ -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
|
||||||
|
@ -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')
|
||||||
|
@ -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
|
||||||
|
@ -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')
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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':
|
||||||
|
@ -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)
|
||||||
|
}
|
||||||
|
@ -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 },
|
||||||
})
|
})
|
||||||
|
@ -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(
|
||||||
|
@ -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>
|
||||||
|
@ -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 },
|
||||||
})
|
})
|
||||||
|
@ -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}` },
|
||||||
})
|
})
|
||||||
|
@ -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')
|
||||||
}
|
}
|
||||||
|
@ -38,6 +38,7 @@ type UpdateTypebotPayload = Partial<
|
|||||||
| 'customDomain'
|
| 'customDomain'
|
||||||
| 'resultsTablePreferences'
|
| 'resultsTablePreferences'
|
||||||
| 'isClosed'
|
| 'isClosed'
|
||||||
|
| 'whatsAppPhoneNumberId'
|
||||||
>
|
>
|
||||||
>
|
>
|
||||||
|
|
||||||
|
@ -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' }
|
||||||
|
}
|
||||||
|
)
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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" />
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
@ -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
|
||||||
|
@ -0,0 +1,8 @@
|
|||||||
|
export const phoneNumberKey = 'whatsapp-phone'
|
||||||
|
|
||||||
|
export const getPhoneNumberFromLocalStorage = () =>
|
||||||
|
localStorage.getItem(phoneNumberKey)
|
||||||
|
|
||||||
|
export const setPhoneNumberInLocalStorage = (phoneNumber: string) => {
|
||||||
|
localStorage.setItem(phoneNumberKey, phoneNumber)
|
||||||
|
}
|
@ -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}
|
||||||
|
@ -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',
|
||||||
})
|
})
|
||||||
|
@ -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" />}
|
||||||
|
@ -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]+$'
|
||||||
|
}
|
||||||
|
}
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
@ -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}"` : ''}]
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
@ -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)
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
})
|
})
|
||||||
|
@ -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) => {
|
||||||
|
34
apps/builder/src/features/telemetry/posthog.tsx
Normal file
34
apps/builder/src/features/telemetry/posthog.tsx
Normal 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 }
|
@ -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,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -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 }
|
||||||
|
})
|
78
apps/builder/src/features/whatsapp/getPhoneNumber.ts
Normal file
78
apps/builder/src/features/whatsapp/getPhoneNumber.ts
Normal 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']
|
||||||
|
}
|
88
apps/builder/src/features/whatsapp/getSystemTokenInfo.ts
Normal file
88
apps/builder/src/features/whatsapp/getSystemTokenInfo.ts
Normal 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']
|
||||||
|
}
|
12
apps/builder/src/features/whatsapp/router.ts
Normal file
12
apps/builder/src/features/whatsapp/router.ts
Normal 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,
|
||||||
|
})
|
@ -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' }
|
||||||
|
})
|
@ -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
|
||||||
|
@ -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'
|
||||||
|
}
|
||||||
|
@ -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
|
||||||
|
@ -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 />
|
||||||
|
31
apps/docs/docs/embed/whatsapp.md
Normal file
31
apps/docs/docs/embed/whatsapp.md
Normal 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" />
|
@ -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>
|
||||||
|
|
||||||
|
BIN
apps/docs/static/img/whatsapp/contact-var.png
vendored
Normal file
BIN
apps/docs/static/img/whatsapp/contact-var.png
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 496 KiB |
BIN
apps/docs/static/img/whatsapp/dev-console.png
vendored
Normal file
BIN
apps/docs/static/img/whatsapp/dev-console.png
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 394 KiB |
@ -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"
|
||||||
},
|
},
|
||||||
|
@ -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 ?? '',
|
||||||
|
}
|
||||||
|
}
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
@ -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}` : ''}`
|
|
||||||
}
|
|
@ -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
|
||||||
|
@ -0,0 +1 @@
|
|||||||
|
export const validateNumber = (inputValue: string) => !isNaN(Number(inputValue))
|
@ -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
|
||||||
|
@ -1,4 +0,0 @@
|
|||||||
import { isValidPhoneNumber } from 'libphonenumber-js'
|
|
||||||
|
|
||||||
export const validatePhoneNumber = (phoneNumber: string) =>
|
|
||||||
isValidPhoneNumber(phoneNumber)
|
|
@ -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 ?? '',
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,4 @@
|
|||||||
|
import { RatingInputBlock } from '@typebot.io/schemas'
|
||||||
|
|
||||||
|
export const validateRatingReply = (reply: string, block: RatingInputBlock) =>
|
||||||
|
Number(reply) <= block.options.length
|
@ -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'
|
||||||
|
@ -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,
|
||||||
|
@ -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) =>
|
||||||
|
@ -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,
|
||||||
|
@ -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: [
|
||||||
|
@ -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,
|
||||||
|
@ -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})
|
||||||
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@ -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 {
|
||||||
|
@ -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) {
|
||||||
|
16
apps/viewer/src/features/chat/helpers/parseDynamicTheme.ts
Normal file
16
apps/viewer/src/features/chat/helpers/parseDynamicTheme.ts
Normal 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
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
@ -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({
|
||||||
|
360
apps/viewer/src/features/chat/helpers/startSession.ts
Normal file
360
apps/viewer/src/features/chat/helpers/startSession.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
@ -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,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
8
apps/viewer/src/features/chat/queries/deleteSession.ts
Normal file
8
apps/viewer/src/features/chat/queries/deleteSession.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import prisma from '@/lib/prisma'
|
||||||
|
|
||||||
|
export const deleteSession = (id: string) =>
|
||||||
|
prisma.chatSession.deleteMany({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
})
|
24
apps/viewer/src/features/chat/queries/restartSession.ts
Normal file
24
apps/viewer/src/features/chat/queries/restartSession.ts
Normal 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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
@ -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' }
|
||||||
|
42
apps/viewer/src/features/whatsApp/api/receiveMessage.ts
Normal file
42
apps/viewer/src/features/whatsApp/api/receiveMessage.ts
Normal 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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
@ -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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
14
apps/viewer/src/features/whatsApp/api/router.ts
Normal file
14
apps/viewer/src/features/whatsApp/api/router.ts
Normal 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,
|
||||||
|
})
|
138
apps/viewer/src/features/whatsApp/api/startWhatsAppPreview.ts
Normal file
138
apps/viewer/src/features/whatsApp/api/startWhatsAppPreview.ts
Normal 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',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
@ -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)
|
||||||
|
}
|
||||||
|
)
|
45
apps/viewer/src/features/whatsApp/api/subscribeWebhook.ts
Normal file
45
apps/viewer/src/features/whatsApp/api/subscribeWebhook.ts
Normal 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)
|
||||||
|
}
|
||||||
|
)
|
@ -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,
|
||||||
|
[]
|
||||||
|
)
|
@ -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
Reference in New Issue
Block a user