Add WhatsApp integration beta test (#722)

Related to #401
This commit is contained in:
Baptiste Arnaud
2023-08-29 10:01:28 +02:00
parent 036b407a11
commit b852b4af0b
136 changed files with 6694 additions and 5383 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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