2
0

(whatsapp) Improve whatsApp management and media collection

Closes #796
This commit is contained in:
Baptiste Arnaud
2023-09-22 11:08:41 +02:00
parent 8f4e5b5d63
commit 9e0109f561
22 changed files with 505 additions and 452 deletions

View File

@ -31,14 +31,16 @@ export const createCredentials = authenticatedProcedure
}) })
.input( .input(
z.object({ z.object({
credentials: z.discriminatedUnion('type', [ credentials: z
stripeCredentialsSchema.pick(inputShape), .discriminatedUnion('type', [
smtpCredentialsSchema.pick(inputShape), stripeCredentialsSchema.pick(inputShape),
googleSheetsCredentialsSchema.pick(inputShape), smtpCredentialsSchema.pick(inputShape),
openAICredentialsSchema.pick(inputShape), googleSheetsCredentialsSchema.pick(inputShape),
whatsAppCredentialsSchema.pick(inputShape), openAICredentialsSchema.pick(inputShape),
zemanticAiCredentialsSchema.pick(inputShape), whatsAppCredentialsSchema.pick(inputShape),
]), zemanticAiCredentialsSchema.pick(inputShape),
])
.and(z.object({ id: z.string().cuid2().optional() })),
}) })
) )
.output( .output(

View File

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

View File

@ -43,6 +43,7 @@ import { env } from '@typebot.io/env'
import { isEmpty, isNotEmpty } from '@typebot.io/lib/utils' import { isEmpty, isNotEmpty } from '@typebot.io/lib/utils'
import { getViewerUrl } from '@typebot.io/lib/getViewerUrl' import { getViewerUrl } from '@typebot.io/lib/getViewerUrl'
import React, { useState } from 'react' import React, { useState } from 'react'
import { createId } from '@paralleldrive/cuid2'
const steps = [ const steps = [
{ title: 'Requirements' }, { title: 'Requirements' },
@ -57,6 +58,8 @@ type Props = {
onNewCredentials: (id: string) => void onNewCredentials: (id: string) => void
} }
const credentialsId = createId()
export const WhatsAppCredentialsModal = ({ export const WhatsAppCredentialsModal = ({
isOpen, isOpen,
onClose, onClose,
@ -115,6 +118,7 @@ export const WhatsAppCredentialsModal = ({
if (!workspace) return if (!workspace) return
mutate({ mutate({
credentials: { credentials: {
id: credentialsId,
type: 'whatsApp', type: 'whatsApp',
workspaceId: workspace.id, workspaceId: workspace.id,
name: phoneNumberName, name: phoneNumberName,
@ -269,7 +273,7 @@ export const WhatsAppCredentialsModal = ({
<Webhook <Webhook
appId={tokenInfoData?.appId} appId={tokenInfoData?.appId}
verificationToken={verificationToken} verificationToken={verificationToken}
phoneNumberId={phoneNumberId} credentialsId={credentialsId}
/> />
)} )}
</ModalBody> </ModalBody>
@ -442,18 +446,16 @@ const PhoneNumber = ({
const Webhook = ({ const Webhook = ({
appId, appId,
verificationToken, verificationToken,
phoneNumberId, credentialsId,
}: { }: {
appId?: string appId?: string
verificationToken: string verificationToken: string
phoneNumberId: string credentialsId: string
}) => { }) => {
const { workspace } = useWorkspace() const { workspace } = useWorkspace()
const webhookUrl = `${ const webhookUrl = `${
env.NEXT_PUBLIC_VIEWER_INTERNAL_URL ?? getViewerUrl() env.NEXT_PUBLIC_VIEWER_INTERNAL_URL ?? getViewerUrl()
}/api/v1/workspaces/${ }/api/v1/workspaces/${workspace?.id}/whatsapp/${credentialsId}/webhook`
workspace?.id
}/whatsapp/phoneNumbers/${phoneNumberId}/webhook`
return ( return (
<Stack spacing={6}> <Stack spacing={6}>

View File

@ -30,7 +30,6 @@ import { PublishButton } from '../../../PublishButton'
import { useParentModal } from '@/features/graph/providers/ParentModalProvider' import { useParentModal } from '@/features/graph/providers/ParentModalProvider'
import { trpc } from '@/lib/trpc' import { trpc } from '@/lib/trpc'
import { SwitchWithLabel } from '@/components/inputs/SwitchWithLabel' import { SwitchWithLabel } from '@/components/inputs/SwitchWithLabel'
import { isDefined } from '@typebot.io/lib/utils'
import { TableList } from '@/components/TableList' import { TableList } from '@/components/TableList'
import { Comparison, LogicalOperator } from '@typebot.io/schemas' import { Comparison, LogicalOperator } from '@typebot.io/schemas'
import { DropdownList } from '@/components/DropdownList' import { DropdownList } from '@/components/DropdownList'
@ -51,18 +50,25 @@ export const WhatsAppModal = ({ isOpen, onClose }: ModalProps): JSX.Element => {
const { data: phoneNumberData } = trpc.whatsApp.getPhoneNumber.useQuery( const { data: phoneNumberData } = trpc.whatsApp.getPhoneNumber.useQuery(
{ {
credentialsId: whatsAppSettings?.credentialsId as string, credentialsId: typebot?.whatsAppCredentialsId as string,
}, },
{ {
enabled: !!whatsAppSettings?.credentialsId, enabled: !!typebot?.whatsAppCredentialsId,
} }
) )
const toggleEnableWhatsApp = (isChecked: boolean) => { const toggleEnableWhatsApp = (isChecked: boolean) => {
if (!phoneNumberData?.id) return if (!phoneNumberData?.id || !typebot) return
updateTypebot({ updateTypebot({
updates: { whatsAppPhoneNumberId: isChecked ? phoneNumberData.id : null }, updates: {
save: true, settings: {
...typebot.settings,
whatsApp: {
...typebot.settings.whatsApp,
isEnabled: isChecked,
},
},
},
}) })
} }
@ -70,13 +76,7 @@ export const WhatsAppModal = ({ isOpen, onClose }: ModalProps): JSX.Element => {
if (!typebot) return if (!typebot) return
updateTypebot({ updateTypebot({
updates: { updates: {
settings: { whatsAppCredentialsId: credentialsId,
...typebot.settings,
whatsApp: {
...typebot.settings.whatsApp,
credentialsId,
},
},
}, },
}) })
} }
@ -148,7 +148,9 @@ export const WhatsAppModal = ({ isOpen, onClose }: ModalProps): JSX.Element => {
<CredentialsDropdown <CredentialsDropdown
type="whatsApp" type="whatsApp"
workspaceId={workspace.id} workspaceId={workspace.id}
currentCredentialsId={whatsAppSettings?.credentialsId} currentCredentialsId={
typebot?.whatsAppCredentialsId ?? undefined
}
onCredentialsSelect={updateCredentialsId} onCredentialsSelect={updateCredentialsId}
onCreateNewClick={onOpen} onCreateNewClick={onOpen}
credentialsName="WA phone number" credentialsName="WA phone number"
@ -158,7 +160,7 @@ export const WhatsAppModal = ({ isOpen, onClose }: ModalProps): JSX.Element => {
)} )}
</HStack> </HStack>
</ListItem> </ListItem>
{typebot?.settings.whatsApp?.credentialsId && ( {typebot?.whatsAppCredentialsId && (
<> <>
<ListItem> <ListItem>
<Accordion allowToggle> <Accordion allowToggle>
@ -196,22 +198,22 @@ export const WhatsAppModal = ({ isOpen, onClose }: ModalProps): JSX.Element => {
</Accordion> </Accordion>
</ListItem> </ListItem>
<ListItem>
<HStack>
<Text>Publish your bot:</Text>
<PublishButton size="sm" isMoreMenuDisabled />
</HStack>
</ListItem>
<ListItem> <ListItem>
<SwitchWithLabel <SwitchWithLabel
label="Enable WhatsApp integration" label="Enable WhatsApp integration"
initialValue={ initialValue={
isDefined(typebot?.whatsAppPhoneNumberId) ? true : false typebot?.settings.whatsApp?.isEnabled ?? false
} }
onCheckChange={toggleEnableWhatsApp} onCheckChange={toggleEnableWhatsApp}
justifyContent="flex-start" justifyContent="flex-start"
/> />
</ListItem> </ListItem>
<ListItem>
<HStack>
<Text>Publish your bot:</Text>
<PublishButton size="sm" isMoreMenuDisabled />
</HStack>
</ListItem>
{phoneNumberData?.id && ( {phoneNumberData?.id && (
<ListItem> <ListItem>
<TextLink <TextLink

View File

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

View File

@ -31,6 +31,7 @@ export const updateTypebot = authenticatedProcedure
.pick({ .pick({
isClosed: true, isClosed: true,
whatsAppPhoneNumberId: true, whatsAppPhoneNumberId: true,
whatsAppCredentialsId: true,
}) })
.partial() .partial()
), ),
@ -151,6 +152,7 @@ export const updateTypebot = authenticatedProcedure
typebot.customDomain === null ? null : typebot.customDomain, typebot.customDomain === null ? null : typebot.customDomain,
isClosed: typebot.isClosed, isClosed: typebot.isClosed,
whatsAppPhoneNumberId: typebot.whatsAppPhoneNumberId ?? undefined, whatsAppPhoneNumberId: typebot.whatsAppPhoneNumberId ?? undefined,
whatsAppCredentialsId: typebot.whatsAppCredentialsId ?? undefined,
}, },
}) })

View File

@ -5,7 +5,6 @@ import prisma from '@typebot.io/lib/prisma'
import { decrypt } from '@typebot.io/lib/api' import { decrypt } from '@typebot.io/lib/api'
import { TRPCError } from '@trpc/server' import { TRPCError } from '@trpc/server'
import { WhatsAppCredentials } from '@typebot.io/schemas/features/whatsapp' import { WhatsAppCredentials } from '@typebot.io/schemas/features/whatsapp'
import { parsePhoneNumber } from 'libphonenumber-js'
const inputSchema = z.object({ const inputSchema = z.object({
credentialsId: z.string().optional(), credentialsId: z.string().optional(),
@ -46,18 +45,13 @@ export const getPhoneNumber = authenticatedProcedure
display_phone_number: string display_phone_number: string
} }
const parsedPhoneNumber = parsePhoneNumber(display_phone_number) const formattedPhoneNumber = `${
display_phone_number.startsWith('+') ? '' : '+'
if (!parsedPhoneNumber.isValid()) }${display_phone_number.replace(/\s-/g, '')}`
throw new TRPCError({
code: 'BAD_REQUEST',
message:
"Phone number is not valid. Make sure you don't provide a WhatsApp test number.",
})
return { return {
id: credentials.phoneNumberId, id: credentials.phoneNumberId,
name: parsedPhoneNumber.formatInternational().replace(/\s/g, ''), name: formattedPhoneNumber,
} }
}) })

View File

@ -35,7 +35,6 @@ export const receiveMessagePreview = publicProcedure
return resumeWhatsAppFlow({ return resumeWhatsAppFlow({
receivedMessage, receivedMessage,
sessionId: `wa-${receivedMessage.from}-preview`, sessionId: `wa-${receivedMessage.from}-preview`,
phoneNumberId: env.WHATSAPP_PREVIEW_FROM_PHONE_NUMBER_ID,
contact: { contact: {
name: contactName, name: contactName,
phoneNumber: contactPhoneNumber, phoneNumber: contactPhoneNumber,

View File

@ -0,0 +1,86 @@
import prisma from '@typebot.io/lib/prisma'
import { NextApiRequest, NextApiResponse } from 'next'
import { getAuthenticatedUser } from '@/features/auth/helpers/getAuthenticatedUser'
import {
decrypt,
methodNotAllowed,
notAuthenticated,
notFound,
} from '@typebot.io/lib/api'
import { isReadWorkspaceFobidden } from '@/features/workspace/helpers/isReadWorkspaceFobidden'
import { WhatsAppCredentials } from '@typebot.io/schemas/features/whatsapp'
import got from 'got'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === 'GET') {
const user = await getAuthenticatedUser(req, res)
if (!user) return notAuthenticated(res)
const typebotId = req.query.typebotId as string
const typebot = await prisma.typebot.findFirst({
where: {
id: typebotId,
},
select: {
whatsAppCredentialsId: true,
workspace: {
select: {
credentials: {
where: {
type: 'whatsApp',
},
},
members: {
select: {
userId: true,
},
},
},
},
},
})
if (!typebot?.workspace || isReadWorkspaceFobidden(typebot.workspace, user))
return notFound(res, 'Workspace not found')
if (!typebot) return notFound(res, 'Typebot not found')
const mediaId = req.query.mediaId as string
const credentialsId = typebot.whatsAppCredentialsId
const credentials = typebot.workspace.credentials.find(
(credential) => credential.id === credentialsId
)
if (!credentials) return notFound(res, 'Credentials not found')
const credentialsData = (await decrypt(
credentials.data,
credentials.iv
)) as WhatsAppCredentials['data']
const { body } = await got.get({
url: `https://graph.facebook.com/v17.0/${mediaId}`,
headers: {
Authorization: `Bearer ${credentialsData.systemUserAccessToken}`,
},
})
const parsedBody = JSON.parse(body) as { url: string; mime_type: string }
const buffer = await got(parsedBody.url, {
headers: {
Authorization: `Bearer ${credentialsData.systemUserAccessToken}`,
},
}).buffer()
res.setHeader('Content-Type', parsedBody.mime_type)
res.setHeader('Cache-Control', 'public, max-age=86400')
return res.send(buffer)
}
return methodNotAllowed(res)
}
export default handler

View File

@ -8633,8 +8633,8 @@
"whatsApp": { "whatsApp": {
"type": "object", "type": "object",
"properties": { "properties": {
"credentialsId": { "isEnabled": {
"type": "string" "type": "boolean"
}, },
"startCondition": { "startCondition": {
"type": "object", "type": "object",
@ -12745,8 +12745,8 @@
"whatsApp": { "whatsApp": {
"type": "object", "type": "object",
"properties": { "properties": {
"credentialsId": { "isEnabled": {
"type": "string" "type": "boolean"
}, },
"startCondition": { "startCondition": {
"type": "object", "type": "object",
@ -12877,6 +12877,10 @@
"whatsAppPhoneNumberId": { "whatsAppPhoneNumberId": {
"type": "string", "type": "string",
"nullable": true "nullable": true
},
"whatsAppCredentialsId": {
"type": "string",
"nullable": true
} }
}, },
"required": [ "required": [
@ -12899,7 +12903,8 @@
"resultsTablePreferences", "resultsTablePreferences",
"isArchived", "isArchived",
"isClosed", "isClosed",
"whatsAppPhoneNumberId" "whatsAppPhoneNumberId",
"whatsAppCredentialsId"
], ],
"additionalProperties": false "additionalProperties": false
} }
@ -16819,8 +16824,8 @@
"whatsApp": { "whatsApp": {
"type": "object", "type": "object",
"properties": { "properties": {
"credentialsId": { "isEnabled": {
"type": "string" "type": "boolean"
}, },
"startCondition": { "startCondition": {
"type": "object", "type": "object",
@ -17018,6 +17023,10 @@
"whatsAppPhoneNumberId": { "whatsAppPhoneNumberId": {
"type": "string", "type": "string",
"nullable": true "nullable": true
},
"whatsAppCredentialsId": {
"type": "string",
"nullable": true
} }
}, },
"additionalProperties": false "additionalProperties": false
@ -20951,8 +20960,8 @@
"whatsApp": { "whatsApp": {
"type": "object", "type": "object",
"properties": { "properties": {
"credentialsId": { "isEnabled": {
"type": "string" "type": "boolean"
}, },
"startCondition": { "startCondition": {
"type": "object", "type": "object",
@ -21083,6 +21092,10 @@
"whatsAppPhoneNumberId": { "whatsAppPhoneNumberId": {
"type": "string", "type": "string",
"nullable": true "nullable": true
},
"whatsAppCredentialsId": {
"type": "string",
"nullable": true
} }
}, },
"required": [ "required": [
@ -21105,7 +21118,8 @@
"resultsTablePreferences", "resultsTablePreferences",
"isArchived", "isArchived",
"isClosed", "isClosed",
"whatsAppPhoneNumberId" "whatsAppPhoneNumberId",
"whatsAppCredentialsId"
], ],
"additionalProperties": false "additionalProperties": false
} }
@ -25049,8 +25063,8 @@
"whatsApp": { "whatsApp": {
"type": "object", "type": "object",
"properties": { "properties": {
"credentialsId": { "isEnabled": {
"type": "string" "type": "boolean"
}, },
"startCondition": { "startCondition": {
"type": "object", "type": "object",
@ -25181,6 +25195,10 @@
"whatsAppPhoneNumberId": { "whatsAppPhoneNumberId": {
"type": "string", "type": "string",
"nullable": true "nullable": true
},
"whatsAppCredentialsId": {
"type": "string",
"nullable": true
} }
}, },
"required": [ "required": [
@ -25203,7 +25221,8 @@
"resultsTablePreferences", "resultsTablePreferences",
"isArchived", "isArchived",
"isClosed", "isClosed",
"whatsAppPhoneNumberId" "whatsAppPhoneNumberId",
"whatsAppCredentialsId"
], ],
"additionalProperties": false "additionalProperties": false
}, },
@ -29206,8 +29225,8 @@
"whatsApp": { "whatsApp": {
"type": "object", "type": "object",
"properties": { "properties": {
"credentialsId": { "isEnabled": {
"type": "string" "type": "boolean"
}, },
"startCondition": { "startCondition": {
"type": "object", "type": "object",
@ -30849,294 +30868,307 @@
"type": "object", "type": "object",
"properties": { "properties": {
"credentials": { "credentials": {
"anyOf": [ "allOf": [
{ {
"type": "object", "anyOf": [
"properties": { {
"data": {
"type": "object", "type": "object",
"properties": { "properties": {
"live": { "data": {
"type": "object", "type": "object",
"properties": { "properties": {
"secretKey": { "live": {
"type": "object",
"properties": {
"secretKey": {
"type": "string"
},
"publicKey": {
"type": "string"
}
},
"required": [
"secretKey",
"publicKey"
],
"additionalProperties": false
},
"test": {
"type": "object",
"properties": {
"secretKey": {
"type": "string"
},
"publicKey": {
"type": "string"
}
},
"additionalProperties": false
}
},
"required": [
"live",
"test"
],
"additionalProperties": false
},
"type": {
"type": "string",
"enum": [
"stripe"
]
},
"workspaceId": {
"type": "string"
},
"name": {
"type": "string"
}
},
"required": [
"data",
"type",
"workspaceId",
"name"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"data": {
"type": "object",
"properties": {
"host": {
"type": "string" "type": "string"
}, },
"publicKey": { "username": {
"type": "string"
},
"password": {
"type": "string"
},
"isTlsEnabled": {
"type": "boolean"
},
"port": {
"type": "number"
},
"from": {
"type": "object",
"properties": {
"email": {
"type": "string"
},
"name": {
"type": "string"
}
},
"additionalProperties": false
}
},
"required": [
"port",
"from"
],
"additionalProperties": false
},
"type": {
"type": "string",
"enum": [
"smtp"
]
},
"workspaceId": {
"type": "string"
},
"name": {
"type": "string"
}
},
"required": [
"data",
"type",
"workspaceId",
"name"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"data": {
"type": "object",
"properties": {
"refresh_token": {
"type": "string",
"nullable": true
},
"expiry_date": {
"type": "number",
"nullable": true
},
"access_token": {
"type": "string",
"nullable": true
},
"token_type": {
"type": "string",
"nullable": true
},
"id_token": {
"type": "string",
"nullable": true
},
"scope": {
"type": "string"
}
},
"additionalProperties": false
},
"type": {
"type": "string",
"enum": [
"google sheets"
]
},
"workspaceId": {
"type": "string"
},
"name": {
"type": "string"
}
},
"required": [
"data",
"type",
"workspaceId",
"name"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"data": {
"type": "object",
"properties": {
"apiKey": {
"type": "string" "type": "string"
} }
}, },
"required": [ "required": [
"secretKey", "apiKey"
"publicKey"
], ],
"additionalProperties": false "additionalProperties": false
}, },
"test": { "type": {
"type": "string",
"enum": [
"openai"
]
},
"workspaceId": {
"type": "string"
},
"name": {
"type": "string"
}
},
"required": [
"data",
"type",
"workspaceId",
"name"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"data": {
"type": "object", "type": "object",
"properties": { "properties": {
"secretKey": { "systemUserAccessToken": {
"type": "string" "type": "string"
}, },
"publicKey": { "phoneNumberId": {
"type": "string" "type": "string"
} }
}, },
"required": [
"systemUserAccessToken",
"phoneNumberId"
],
"additionalProperties": false "additionalProperties": false
},
"type": {
"type": "string",
"enum": [
"whatsApp"
]
},
"workspaceId": {
"type": "string"
},
"name": {
"type": "string"
} }
}, },
"required": [ "required": [
"live", "data",
"test" "type",
"workspaceId",
"name"
], ],
"additionalProperties": false "additionalProperties": false
}, },
"type": { {
"type": "string",
"enum": [
"stripe"
]
},
"workspaceId": {
"type": "string"
},
"name": {
"type": "string"
}
},
"required": [
"data",
"type",
"workspaceId",
"name"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"data": {
"type": "object", "type": "object",
"properties": { "properties": {
"host": { "data": {
"type": "string"
},
"username": {
"type": "string"
},
"password": {
"type": "string"
},
"isTlsEnabled": {
"type": "boolean"
},
"port": {
"type": "number"
},
"from": {
"type": "object", "type": "object",
"properties": { "properties": {
"email": { "apiKey": {
"type": "string"
},
"name": {
"type": "string" "type": "string"
} }
}, },
"required": [
"apiKey"
],
"additionalProperties": false "additionalProperties": false
} },
}, "type": {
"required": [
"port",
"from"
],
"additionalProperties": false
},
"type": {
"type": "string",
"enum": [
"smtp"
]
},
"workspaceId": {
"type": "string"
},
"name": {
"type": "string"
}
},
"required": [
"data",
"type",
"workspaceId",
"name"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"data": {
"type": "object",
"properties": {
"refresh_token": {
"type": "string", "type": "string",
"nullable": true "enum": [
"zemanticAi"
]
}, },
"expiry_date": { "workspaceId": {
"type": "number",
"nullable": true
},
"access_token": {
"type": "string",
"nullable": true
},
"token_type": {
"type": "string",
"nullable": true
},
"id_token": {
"type": "string",
"nullable": true
},
"scope": {
"type": "string" "type": "string"
} },
}, "name": {
"additionalProperties": false
},
"type": {
"type": "string",
"enum": [
"google sheets"
]
},
"workspaceId": {
"type": "string"
},
"name": {
"type": "string"
}
},
"required": [
"data",
"type",
"workspaceId",
"name"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"data": {
"type": "object",
"properties": {
"apiKey": {
"type": "string" "type": "string"
} }
}, },
"required": [ "required": [
"apiKey" "data",
"type",
"workspaceId",
"name"
], ],
"additionalProperties": false "additionalProperties": false
},
"type": {
"type": "string",
"enum": [
"openai"
]
},
"workspaceId": {
"type": "string"
},
"name": {
"type": "string"
} }
}, ]
"required": [
"data",
"type",
"workspaceId",
"name"
],
"additionalProperties": false
}, },
{ {
"type": "object", "type": "object",
"properties": { "properties": {
"data": { "id": {
"type": "object",
"properties": {
"systemUserAccessToken": {
"type": "string"
},
"phoneNumberId": {
"type": "string"
}
},
"required": [
"systemUserAccessToken",
"phoneNumberId"
],
"additionalProperties": false
},
"type": {
"type": "string", "type": "string",
"enum": [ "pattern": "^[a-z][a-z0-9]*$"
"whatsApp"
]
},
"workspaceId": {
"type": "string"
},
"name": {
"type": "string"
} }
}, }
"required": [
"data",
"type",
"workspaceId",
"name"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"data": {
"type": "object",
"properties": {
"apiKey": {
"type": "string"
}
},
"required": [
"apiKey"
],
"additionalProperties": false
},
"type": {
"type": "string",
"enum": [
"zemanticAi"
]
},
"workspaceId": {
"type": "string"
},
"name": {
"type": "string"
}
},
"required": [
"data",
"type",
"workspaceId",
"name"
],
"additionalProperties": false
} }
] ]
} }

View File

@ -3761,8 +3761,8 @@
"whatsApp": { "whatsApp": {
"type": "object", "type": "object",
"properties": { "properties": {
"credentialsId": { "isEnabled": {
"type": "string" "type": "boolean"
}, },
"startCondition": { "startCondition": {
"type": "object", "type": "object",
@ -6172,8 +6172,8 @@
"whatsApp": { "whatsApp": {
"type": "object", "type": "object",
"properties": { "properties": {
"credentialsId": { "isEnabled": {
"type": "string" "type": "boolean"
}, },
"startCondition": { "startCondition": {
"type": "object", "type": "object",
@ -6350,17 +6350,12 @@
"presignedUrl": { "presignedUrl": {
"type": "string" "type": "string"
}, },
"formData": {
"type": "object",
"additionalProperties": {}
},
"hasReachedStorageLimit": { "hasReachedStorageLimit": {
"type": "boolean" "type": "boolean"
} }
}, },
"required": [ "required": [
"presignedUrl", "presignedUrl",
"formData",
"hasReachedStorageLimit" "hasReachedStorageLimit"
], ],
"additionalProperties": false "additionalProperties": false
@ -6508,7 +6503,7 @@
} }
} }
}, },
"/workspaces/{workspaceId}/whatsapp/phoneNumbers/{phoneNumberId}/webhook": { "/workspaces/{workspaceId}/whatsapp/{credentialsId}/webhook": {
"get": { "get": {
"operationId": "whatsAppRouter-subscribeWebhook", "operationId": "whatsAppRouter-subscribeWebhook",
"summary": "Subscribe webhook", "summary": "Subscribe webhook",
@ -6530,7 +6525,7 @@
} }
}, },
{ {
"name": "phoneNumberId", "name": "credentialsId",
"in": "path", "in": "path",
"required": true, "required": true,
"schema": { "schema": {
@ -6932,7 +6927,7 @@
} }
}, },
{ {
"name": "phoneNumberId", "name": "credentialsId",
"in": "path", "in": "path",
"required": true, "required": true,
"schema": { "schema": {

View File

@ -8,14 +8,14 @@ export const receiveMessage = publicProcedure
.meta({ .meta({
openapi: { openapi: {
method: 'POST', method: 'POST',
path: '/workspaces/{workspaceId}/whatsapp/phoneNumbers/{phoneNumberId}/webhook', path: '/workspaces/{workspaceId}/whatsapp/{credentialsId}/webhook',
summary: 'Message webhook', summary: 'Message webhook',
tags: ['WhatsApp'], tags: ['WhatsApp'],
}, },
}) })
.input( .input(
z z
.object({ workspaceId: z.string(), phoneNumberId: z.string() }) .object({ workspaceId: z.string(), credentialsId: z.string() })
.merge(whatsAppWebhookRequestBodySchema) .merge(whatsAppWebhookRequestBodySchema)
) )
.output( .output(
@ -23,7 +23,7 @@ export const receiveMessage = publicProcedure
message: z.string(), message: z.string(),
}) })
) )
.mutation(async ({ input: { entry, workspaceId, phoneNumberId } }) => { .mutation(async ({ input: { entry, workspaceId, credentialsId } }) => {
const receivedMessage = entry.at(0)?.changes.at(0)?.value.messages?.at(0) const receivedMessage = entry.at(0)?.changes.at(0)?.value.messages?.at(0)
if (isNotDefined(receivedMessage)) return { message: 'No message found' } if (isNotDefined(receivedMessage)) return { message: 'No message found' }
const contactName = const contactName =
@ -32,8 +32,8 @@ export const receiveMessage = publicProcedure
entry.at(0)?.changes.at(0)?.value?.messages?.at(0)?.from ?? '' entry.at(0)?.changes.at(0)?.value?.messages?.at(0)?.from ?? ''
return resumeWhatsAppFlow({ return resumeWhatsAppFlow({
receivedMessage, receivedMessage,
sessionId: `wa-${phoneNumberId}-${receivedMessage.from}`, sessionId: `wa-${credentialsId}-${receivedMessage.from}`,
phoneNumberId, credentialsId,
workspaceId, workspaceId,
contact: { contact: {
name: contactName, name: contactName,

View File

@ -7,7 +7,7 @@ export const subscribeWebhook = publicProcedure
.meta({ .meta({
openapi: { openapi: {
method: 'GET', method: 'GET',
path: '/workspaces/{workspaceId}/whatsapp/phoneNumbers/{phoneNumberId}/webhook', path: '/workspaces/{workspaceId}/whatsapp/{credentialsId}/webhook',
summary: 'Subscribe webhook', summary: 'Subscribe webhook',
tags: ['WhatsApp'], tags: ['WhatsApp'],
protect: true, protect: true,
@ -16,7 +16,7 @@ export const subscribeWebhook = publicProcedure
.input( .input(
z.object({ z.object({
workspaceId: z.string(), workspaceId: z.string(),
phoneNumberId: z.string(), credentialsId: z.string(),
'hub.challenge': z.string(), 'hub.challenge': z.string(),
'hub.verify_token': z.string(), 'hub.verify_token': z.string(),
}) })

View File

@ -1,46 +0,0 @@
import got from 'got'
import { TRPCError } from '@trpc/server'
import { uploadFileToBucket } from '@typebot.io/lib/s3/uploadFileToBucket'
type Props = {
mediaId: string
systemUserToken: string
downloadPath: string
}
export const downloadMedia = async ({
mediaId,
systemUserToken,
downloadPath,
}: Props) => {
const { body } = await got.get({
url: `https://graph.facebook.com/v17.0/${mediaId}`,
headers: {
Authorization: `Bearer ${systemUserToken}`,
},
})
const parsedBody = JSON.parse(body) as { url: string; mime_type: string }
if (!parsedBody.url)
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Request to Facebook failed. Could not find media url.',
cause: body,
})
const streamBuffer = await got(parsedBody.url, {
headers: {
Authorization: `Bearer ${systemUserToken}`,
},
}).buffer()
const typebotUrl = await uploadFileToBucket({
fileName: `public/${downloadPath}/${mediaId}`,
file: streamBuffer,
mimeType: parsedBody.mime_type,
})
await got.delete({
url: `https://graph.facebook.com/v17.0/${mediaId}`,
headers: {
Authorization: `Bearer ${systemUserToken}`,
},
})
return typebotUrl
}

View File

@ -6,7 +6,6 @@ import {
import { env } from '@typebot.io/env' import { env } from '@typebot.io/env'
import { sendChatReplyToWhatsApp } from './sendChatReplyToWhatsApp' import { sendChatReplyToWhatsApp } from './sendChatReplyToWhatsApp'
import { startWhatsAppSession } from './startWhatsAppSession' import { startWhatsAppSession } from './startWhatsAppSession'
import { downloadMedia } from './downloadMedia'
import { getSession } from '../queries/getSession' import { getSession } from '../queries/getSession'
import { continueBotFlow } from '../continueBotFlow' import { continueBotFlow } from '../continueBotFlow'
import { decrypt } from '@typebot.io/lib/api' import { decrypt } from '@typebot.io/lib/api'
@ -17,12 +16,12 @@ export const resumeWhatsAppFlow = async ({
receivedMessage, receivedMessage,
sessionId, sessionId,
workspaceId, workspaceId,
phoneNumberId, credentialsId,
contact, contact,
}: { }: {
receivedMessage: WhatsAppIncomingMessage receivedMessage: WhatsAppIncomingMessage
sessionId: string sessionId: string
phoneNumberId: string credentialsId?: string
workspaceId?: string workspaceId?: string
contact: NonNullable<SessionState['whatsApp']>['contact'] contact: NonNullable<SessionState['whatsApp']>['contact']
}) => { }) => {
@ -38,22 +37,14 @@ export const resumeWhatsAppFlow = async ({
const session = await getSession(sessionId) const session = await getSession(sessionId)
const initialCredentials = session const isPreview = workspaceId === undefined || credentialsId === undefined
? await getCredentials(phoneNumberId)(session.state)
: undefined
const { typebot, resultId } = session?.state.typebotsQueue[0] ?? {} const { typebot } = session?.state.typebotsQueue[0] ?? {}
const messageContent = await getIncomingMessageContent({ const messageContent = await getIncomingMessageContent({
message: receivedMessage, message: receivedMessage,
systemUserToken: initialCredentials?.systemUserAccessToken, typebotId: typebot?.id,
downloadPath:
typebot && resultId
? `typebots/${typebot.id}/results/${resultId}`
: undefined,
}) })
const isPreview = workspaceId === undefined
const sessionState = const sessionState =
isPreview && session?.state isPreview && session?.state
? ({ ? ({
@ -64,6 +55,15 @@ export const resumeWhatsAppFlow = async ({
} satisfies SessionState) } satisfies SessionState)
: session?.state : session?.state
const credentials = await getCredentials({ credentialsId, isPreview })
if (!credentials) {
console.error('Could not find credentials')
return {
message: 'Message received',
}
}
const resumeResponse = sessionState const resumeResponse = sessionState
? await continueBotFlow(sessionState)(messageContent) ? await continueBotFlow(sessionState)(messageContent)
: workspaceId : workspaceId
@ -71,7 +71,7 @@ export const resumeWhatsAppFlow = async ({
message: receivedMessage, message: receivedMessage,
sessionId, sessionId,
workspaceId, workspaceId,
phoneNumberId, credentials: { ...credentials, id: credentialsId as string },
contact, contact,
}) })
: undefined : undefined
@ -83,17 +83,6 @@ export const resumeWhatsAppFlow = async ({
} }
} }
const credentials =
initialCredentials ??
(await getCredentials(phoneNumberId)(resumeResponse.newSessionState))
if (!credentials) {
console.error('Could not find credentials')
return {
message: 'Message received',
}
}
const { input, logs, newSessionState, messages, clientSideActions } = const { input, logs, newSessionState, messages, clientSideActions } =
resumeResponse resumeResponse
@ -127,12 +116,10 @@ export const resumeWhatsAppFlow = async ({
const getIncomingMessageContent = async ({ const getIncomingMessageContent = async ({
message, message,
systemUserToken, typebotId,
downloadPath,
}: { }: {
message: WhatsAppIncomingMessage message: WhatsAppIncomingMessage
systemUserToken: string | undefined typebotId?: string
downloadPath?: string
}): Promise<string | undefined> => { }): Promise<string | undefined> => {
switch (message.type) { switch (message.type) {
case 'text': case 'text':
@ -147,46 +134,52 @@ const getIncomingMessageContent = async ({
return return
case 'video': case 'video':
case 'image': case 'image':
if (!systemUserToken || !downloadPath) return '' if (!typebotId) return
return downloadMedia({ const mediaId = 'video' in message ? message.video.id : message.image.id
mediaId: 'video' in message ? message.video.id : message.image.id, return (
systemUserToken, env.NEXTAUTH_URL +
downloadPath, `/api/typebots/${typebotId}/whatsapp/media/${mediaId}`
}) )
} }
} }
const getCredentials = const getCredentials = async ({
(phoneNumberId: string) => credentialsId,
async ( isPreview,
state: SessionState }: {
): Promise<WhatsAppCredentials['data'] | undefined> => { credentialsId?: string
const isPreview = !state.typebotsQueue[0].resultId isPreview: boolean
if (isPreview) { }): Promise<WhatsAppCredentials['data'] | undefined> => {
if (!env.META_SYSTEM_USER_TOKEN) return if (isPreview) {
return { if (
systemUserAccessToken: env.META_SYSTEM_USER_TOKEN, !env.META_SYSTEM_USER_TOKEN ||
phoneNumberId, !env.WHATSAPP_PREVIEW_FROM_PHONE_NUMBER_ID
} )
} return
if (!state.whatsApp) return
const credentials = await prisma.credentials.findUnique({
where: {
id: state.whatsApp.credentialsId,
},
select: {
data: true,
iv: true,
},
})
if (!credentials) return
const data = (await decrypt(
credentials.data,
credentials.iv
)) as WhatsAppCredentials['data']
return { return {
systemUserAccessToken: data.systemUserAccessToken, systemUserAccessToken: env.META_SYSTEM_USER_TOKEN,
phoneNumberId, phoneNumberId: env.WHATSAPP_PREVIEW_FROM_PHONE_NUMBER_ID,
} }
} }
if (!credentialsId) return
const credentials = await prisma.credentials.findUnique({
where: {
id: credentialsId,
},
select: {
data: true,
iv: true,
},
})
if (!credentials) return
const data = (await decrypt(
credentials.data,
credentials.iv
)) as WhatsAppCredentials['data']
return {
systemUserAccessToken: data.systemUserAccessToken,
phoneNumberId: data.phoneNumberId,
}
}

View File

@ -13,21 +13,20 @@ import {
WhatsAppIncomingMessage, WhatsAppIncomingMessage,
} from '@typebot.io/schemas/features/whatsapp' } from '@typebot.io/schemas/features/whatsapp'
import { isNotDefined } from '@typebot.io/lib/utils' import { isNotDefined } from '@typebot.io/lib/utils'
import { decrypt } from '@typebot.io/lib/api/encryption'
import { startSession } from '../startSession' import { startSession } from '../startSession'
type Props = { type Props = {
message: WhatsAppIncomingMessage message: WhatsAppIncomingMessage
sessionId: string sessionId: string
workspaceId?: string workspaceId?: string
phoneNumberId: string credentials: WhatsAppCredentials['data'] & Pick<WhatsAppCredentials, 'id'>
contact: NonNullable<SessionState['whatsApp']>['contact'] contact: NonNullable<SessionState['whatsApp']>['contact']
} }
export const startWhatsAppSession = async ({ export const startWhatsAppSession = async ({
message, message,
workspaceId, workspaceId,
phoneNumberId, credentials,
contact, contact,
}: Props): Promise< }: Props): Promise<
| (ChatReply & { | (ChatReply & {
@ -38,7 +37,7 @@ export const startWhatsAppSession = async ({
const publicTypebotsWithWhatsAppEnabled = const publicTypebotsWithWhatsAppEnabled =
(await prisma.publicTypebot.findMany({ (await prisma.publicTypebot.findMany({
where: { where: {
typebot: { workspaceId, whatsAppPhoneNumberId: phoneNumberId }, typebot: { workspaceId, whatsAppCredentialsId: credentials.id },
}, },
select: { select: {
settings: true, settings: true,
@ -55,7 +54,7 @@ export const startWhatsAppSession = async ({
const botsWithWhatsAppEnabled = publicTypebotsWithWhatsAppEnabled.filter( const botsWithWhatsAppEnabled = publicTypebotsWithWhatsAppEnabled.filter(
(publicTypebot) => (publicTypebot) =>
publicTypebot.typebot.publicId && publicTypebot.typebot.publicId &&
publicTypebot.settings.whatsApp?.credentialsId publicTypebot.settings.whatsApp?.isEnabled
) )
const publicTypebot = const publicTypebot =
@ -70,19 +69,6 @@ export const startWhatsAppSession = async ({
if (isNotDefined(publicTypebot)) return if (isNotDefined(publicTypebot)) return
const encryptedCredentials = await prisma.credentials.findUnique({
where: {
id: publicTypebot.settings.whatsApp?.credentialsId,
},
})
if (!encryptedCredentials) return
const credentials = (await decrypt(
encryptedCredentials?.data,
encryptedCredentials?.iv
)) as WhatsAppCredentials['data']
if (credentials.phoneNumberId !== phoneNumberId) return
const session = await startSession({ const session = await startSession({
startParams: { startParams: {
typebot: publicTypebot.typebot.publicId as string, typebot: publicTypebot.typebot.publicId as string,
@ -96,8 +82,7 @@ export const startWhatsAppSession = async ({
...session.newSessionState, ...session.newSessionState,
whatsApp: { whatsApp: {
contact, contact,
credentialsId: publicTypebot?.settings.whatsApp credentialsId: credentials.id,
?.credentialsId as string,
}, },
}, },
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -57,6 +57,7 @@ export const typebotSchema = z.preprocess(
isArchived: z.boolean(), isArchived: z.boolean(),
isClosed: z.boolean(), isClosed: z.boolean(),
whatsAppPhoneNumberId: z.string().nullable(), whatsAppPhoneNumberId: z.string().nullable(),
whatsAppCredentialsId: z.string().nullable(),
}) satisfies z.ZodType<TypebotPrisma, z.ZodTypeDef, unknown> }) satisfies z.ZodType<TypebotPrisma, z.ZodTypeDef, unknown>
) )

View File

@ -188,7 +188,7 @@ const startConditionSchema = z.object({
}) })
export const whatsAppSettingsSchema = z.object({ export const whatsAppSettingsSchema = z.object({
credentialsId: z.string().optional(), isEnabled: z.boolean().optional(),
startCondition: startConditionSchema.optional(), startCondition: startConditionSchema.optional(),
}) })