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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -149,7 +149,7 @@ test.describe.parallel('Google sheets integration', () => {
const fillInSpreadsheetInfo = async (page: Page) => {
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.fill('input[placeholder="Search for spreadsheet"]', 'CR')

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,6 +10,7 @@ import React from 'react'
import { VariableSearchInput } from '@/components/inputs/VariableSearchInput'
import { SwitchWithLabel } from '@/components/inputs/SwitchWithLabel'
import { Select } from '@/components/inputs/Select'
import { WhatsAppLogo } from '@/components/logos/WhatsAppLogo'
type Props = {
options: SetVariableOptions
@@ -47,7 +48,14 @@ export const SetVariableSettings = ({ options, onOptionsChange }: Props) => {
</Text>
<Select
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}
/>
<SetVariableValue options={options} onOptionsChange={onOptionsChange} />
@@ -150,6 +158,8 @@ const SetVariableValue = ({
</Alert>
)
}
case 'Contact name':
case 'Phone number':
case 'Random ID':
case 'Now':
case 'Today':

View File

@@ -8,6 +8,9 @@ import { smtpCredentialsSchema } from '@typebot.io/schemas/features/blocks/integ
import { encrypt } from '@typebot.io/lib/api/encryption'
import { z } from 'zod'
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 = {
data: true,
@@ -33,6 +36,7 @@ export const createCredentials = authenticatedProcedure
smtpCredentialsSchema.pick(inputShape),
googleSheetsCredentialsSchema.pick(inputShape),
openAICredentialsSchema.pick(inputShape),
whatsAppCredentialsSchema.pick(inputShape),
]),
})
)
@@ -42,6 +46,11 @@ export const createCredentials = authenticatedProcedure
})
)
.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({
where: {
id: credentials.workspaceId,
@@ -64,3 +73,14 @@ export const createCredentials = authenticatedProcedure
})
return { credentialsId: createdCredentials.id }
})
const isNotAvailable = async (name: string, type: Credentials['type']) => {
if (type !== 'whatsApp') return
const existingCredentials = await prisma.credentials.findFirst({
where: {
type,
name,
},
})
return isDefined(existingCredentials)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -44,7 +44,7 @@ export const ApiPreviewInstructions = (props: StackProps) => {
w="full"
{...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)
}

View File

@@ -32,11 +32,17 @@ import { trpc } from '@/lib/trpc'
import { useToast } from '@/hooks/useToast'
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 warningTextColor = useColorModeValue('red.300', 'red.600')
const { workspace } = useWorkspace()
const { push, query } = useRouter()
const { push, query, pathname } = useRouter()
const { isOpen, onOpen, onClose } = useDisclosure()
const {
isPublished,
@@ -66,7 +72,8 @@ export const PublishButton = (props: ButtonProps) => {
refetchPublishedTypebot({
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}
isDisabled={isPublished || isSavingLoading}
onClick={handlePublishClick}
borderRightRadius={publishedTypebot ? 0 : undefined}
borderRightRadius={
publishedTypebot && !isMoreMenuDisabled ? 0 : undefined
}
{...props}
>
{isPublished
@@ -164,7 +173,7 @@ export const PublishButton = (props: ButtonProps) => {
</Button>
</Tooltip>
{publishedTypebot && (
{!isMoreMenuDisabled && publishedTypebot && (
<Menu>
<MenuButton
as={IconButton}

View File

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

View File

@@ -39,6 +39,10 @@ import { FlutterFlowLogo } from './logos/FlutterFlowLogo'
import { FlutterFlowModal } from './modals/FlutterFlowModal'
import { NextjsLogo } from './logos/NextjsLogo'
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 = {
publicId: string
@@ -79,6 +83,19 @@ export const EmbedButton = ({
}
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'>) => (
<EmbedButton
logo={<WordpressLogo height={100} width="70px" />}

View File

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

View File

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

View File

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

View File

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

View File

@@ -41,7 +41,7 @@ export const parseReactBotProps = ({ typebot, apiHost }: BotProps) => {
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@${packageJson.version}/dist/web.js'`
@@ -64,6 +64,6 @@ export const parseApiHost = (
export const parseApiHostValue = (
customDomain: Typebot['customDomain'] | undefined
) => {
if (isCloudProdInstance) return
if (isCloudProdInstance()) return
return parseApiHost(customDomain)
}

View File

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

View File

@@ -14,6 +14,7 @@ export const processTelemetryEvent = authenticatedProcedure
path: '/t/process',
description:
"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(
@@ -26,19 +27,19 @@ export const processTelemetryEvent = authenticatedProcedure
message: z.literal('Events injected'),
})
)
.query(async ({ input: { events }, ctx: { user } }) => {
.mutation(async ({ input: { events }, ctx: { user } }) => {
if (user.email !== env.ADMIN_EMAIL)
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Only app admin can process telemetry events',
})
if (!env.POSTHOG_API_KEY)
if (!env.NEXT_PUBLIC_POSTHOG_KEY)
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Server does not have POSTHOG_API_KEY configured',
})
const client = new PostHog(env.POSTHOG_API_KEY, {
host: 'https://eu.posthog.com',
const client = new PostHog(env.NEXT_PUBLIC_POSTHOG_KEY, {
host: env.NEXT_PUBLIC_POSTHOG_HOST,
})
events.forEach(async (event) => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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