✨ (whatsapp) Add custom session expiration (#842)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ### Summary by CodeRabbit - New Feature: Introduced session expiry timeout for WhatsApp integration, allowing users to set the duration after which a session expires. - New Feature: Added an option to enable/disable the start bot condition in WhatsApp integration settings. - Refactor: Enhanced error handling by throwing specific errors when necessary conditions are not met. - Refactor: Improved UI components like `NumberInput` and `SwitchWithLabel` for better usability. - Bug Fix: Fixed issues related to session resumption and message sending in expired sessions. Now, if a session is expired, a new one will be started instead of attempting to resume the old one. - Chore: Updated various schemas to reflect changes in session management and WhatsApp settings. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -10,6 +10,7 @@ import {
|
|||||||
FormControl,
|
FormControl,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
Stack,
|
Stack,
|
||||||
|
Text,
|
||||||
} from '@chakra-ui/react'
|
} from '@chakra-ui/react'
|
||||||
import { Variable, VariableString } from '@typebot.io/schemas'
|
import { Variable, VariableString } from '@typebot.io/schemas'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
@@ -29,6 +30,7 @@ type Props<HasVariable extends boolean> = {
|
|||||||
moreInfoTooltip?: string
|
moreInfoTooltip?: string
|
||||||
isRequired?: boolean
|
isRequired?: boolean
|
||||||
direction?: 'row' | 'column'
|
direction?: 'row' | 'column'
|
||||||
|
suffix?: string
|
||||||
onValueChange: (value?: Value<HasVariable>) => void
|
onValueChange: (value?: Value<HasVariable>) => void
|
||||||
} & Omit<NumberInputProps, 'defaultValue' | 'value' | 'onChange' | 'isRequired'>
|
} & Omit<NumberInputProps, 'defaultValue' | 'value' | 'onChange' | 'isRequired'>
|
||||||
|
|
||||||
@@ -41,6 +43,7 @@ export const NumberInput = <HasVariable extends boolean>({
|
|||||||
moreInfoTooltip,
|
moreInfoTooltip,
|
||||||
isRequired,
|
isRequired,
|
||||||
direction,
|
direction,
|
||||||
|
suffix,
|
||||||
...props
|
...props
|
||||||
}: Props<HasVariable>) => {
|
}: Props<HasVariable>) => {
|
||||||
const [value, setValue] = useState(defaultValue?.toString() ?? '')
|
const [value, setValue] = useState(defaultValue?.toString() ?? '')
|
||||||
@@ -99,24 +102,27 @@ export const NumberInput = <HasVariable extends boolean>({
|
|||||||
isRequired={isRequired}
|
isRequired={isRequired}
|
||||||
justifyContent="space-between"
|
justifyContent="space-between"
|
||||||
width={label ? 'full' : 'auto'}
|
width={label ? 'full' : 'auto'}
|
||||||
spacing={0}
|
spacing={direction === 'column' ? 2 : 3}
|
||||||
>
|
>
|
||||||
{label && (
|
{label && (
|
||||||
<FormLabel mb="2" flexShrink={0}>
|
<FormLabel mb="0" mr="0" flexShrink={0}>
|
||||||
{label}{' '}
|
{label}{' '}
|
||||||
{moreInfoTooltip && (
|
{moreInfoTooltip && (
|
||||||
<MoreInfoTooltip>{moreInfoTooltip}</MoreInfoTooltip>
|
<MoreInfoTooltip>{moreInfoTooltip}</MoreInfoTooltip>
|
||||||
)}
|
)}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
)}
|
)}
|
||||||
{withVariableButton ?? true ? (
|
<HStack>
|
||||||
<HStack spacing={0}>
|
{withVariableButton ?? true ? (
|
||||||
{Input}
|
<HStack spacing="0">
|
||||||
<VariablesButton onSelectVariable={handleVariableSelected} />
|
{Input}
|
||||||
</HStack>
|
<VariablesButton onSelectVariable={handleVariableSelected} />
|
||||||
) : (
|
</HStack>
|
||||||
Input
|
) : (
|
||||||
)}
|
Input
|
||||||
|
)}
|
||||||
|
{suffix ? <Text>{suffix}</Text> : null}
|
||||||
|
</HStack>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export type SwitchWithLabelProps = {
|
|||||||
label: string
|
label: string
|
||||||
initialValue: boolean
|
initialValue: boolean
|
||||||
moreInfoContent?: string
|
moreInfoContent?: string
|
||||||
onCheckChange: (isChecked: boolean) => void
|
onCheckChange?: (isChecked: boolean) => void
|
||||||
justifyContent?: FormControlProps['justifyContent']
|
justifyContent?: FormControlProps['justifyContent']
|
||||||
} & Omit<SwitchProps, 'value' | 'justifyContent'>
|
} & Omit<SwitchProps, 'value' | 'justifyContent'>
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@ export const SwitchWithLabel = ({
|
|||||||
|
|
||||||
const handleChange = () => {
|
const handleChange = () => {
|
||||||
setIsChecked(!isChecked)
|
setIsChecked(!isChecked)
|
||||||
onCheckChange(!isChecked)
|
if (onCheckChange) onCheckChange(!isChecked)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { TextInput, NumberInput } from '@/components/inputs'
|
import { TextInput, NumberInput } from '@/components/inputs'
|
||||||
import { HStack, Stack, Text } from '@chakra-ui/react'
|
import { Stack, Text } from '@chakra-ui/react'
|
||||||
import { EmbedBubbleContent } from '@typebot.io/schemas'
|
import { EmbedBubbleContent } from '@typebot.io/schemas'
|
||||||
import { sanitizeUrl } from '@typebot.io/lib'
|
import { sanitizeUrl } from '@typebot.io/lib'
|
||||||
import { useScopedI18n } from '@/locales'
|
import { useScopedI18n } from '@/locales'
|
||||||
@@ -34,14 +34,13 @@ export const EmbedUploadContent = ({ content, onSubmit }: Props) => {
|
|||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<HStack>
|
<NumberInput
|
||||||
<NumberInput
|
label="Height:"
|
||||||
label="Height:"
|
defaultValue={content?.height}
|
||||||
defaultValue={content?.height}
|
onValueChange={handleHeightChange}
|
||||||
onValueChange={handleHeightChange}
|
suffix={scopedT('numberInput.unit')}
|
||||||
/>
|
width="150px"
|
||||||
<Text>{scopedT('numberInput.unit')}</Text>
|
/>
|
||||||
</HStack>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ import { Comparison, LogicalOperator } from '@typebot.io/schemas'
|
|||||||
import { DropdownList } from '@/components/DropdownList'
|
import { DropdownList } from '@/components/DropdownList'
|
||||||
import { WhatsAppComparisonItem } from './WhatsAppComparisonItem'
|
import { WhatsAppComparisonItem } from './WhatsAppComparisonItem'
|
||||||
import { AlertInfo } from '@/components/AlertInfo'
|
import { AlertInfo } from '@/components/AlertInfo'
|
||||||
|
import { NumberInput } from '@/components/inputs'
|
||||||
|
import { defaultSessionExpiryTimeout } from '@typebot.io/schemas/features/whatsapp'
|
||||||
|
import { SwitchWithRelatedSettings } from '@/components/SwitchWithRelatedSettings'
|
||||||
|
import { isDefined } from '@typebot.io/lib/utils'
|
||||||
|
|
||||||
export const WhatsAppModal = ({ isOpen, onClose }: ModalProps): JSX.Element => {
|
export const WhatsAppModal = ({ isOpen, onClose }: ModalProps): JSX.Element => {
|
||||||
const { typebot, updateTypebot, isPublished } = useTypebot()
|
const { typebot, updateTypebot, isPublished } = useTypebot()
|
||||||
@@ -122,6 +126,46 @@ export const WhatsAppModal = ({ isOpen, onClose }: ModalProps): JSX.Element => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updateIsStartConditionEnabled = (isEnabled: boolean) => {
|
||||||
|
if (!typebot) return
|
||||||
|
updateTypebot({
|
||||||
|
updates: {
|
||||||
|
settings: {
|
||||||
|
...typebot.settings,
|
||||||
|
whatsApp: {
|
||||||
|
...typebot.settings.whatsApp,
|
||||||
|
startCondition: !isEnabled
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
comparisons: [],
|
||||||
|
logicalOperator: LogicalOperator.AND,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateSessionExpiryTimeout = (sessionExpiryTimeout?: number) => {
|
||||||
|
if (
|
||||||
|
!typebot ||
|
||||||
|
(sessionExpiryTimeout &&
|
||||||
|
(sessionExpiryTimeout <= 0 || sessionExpiryTimeout > 48))
|
||||||
|
)
|
||||||
|
return
|
||||||
|
updateTypebot({
|
||||||
|
updates: {
|
||||||
|
settings: {
|
||||||
|
...typebot.settings,
|
||||||
|
whatsApp: {
|
||||||
|
...typebot.settings.whatsApp,
|
||||||
|
sessionExpiryTimeout,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={onClose} size="xl">
|
<Modal isOpen={isOpen} onClose={onClose} size="xl">
|
||||||
<ModalOverlay />
|
<ModalOverlay />
|
||||||
@@ -166,33 +210,58 @@ export const WhatsAppModal = ({ isOpen, onClose }: ModalProps): JSX.Element => {
|
|||||||
<Accordion allowToggle>
|
<Accordion allowToggle>
|
||||||
<AccordionItem>
|
<AccordionItem>
|
||||||
<AccordionButton justifyContent="space-between">
|
<AccordionButton justifyContent="space-between">
|
||||||
Start flow only if
|
Configure integration
|
||||||
<AccordionIcon />
|
<AccordionIcon />
|
||||||
</AccordionButton>
|
</AccordionButton>
|
||||||
<AccordionPanel as={Stack} spacing="4" pt="4">
|
<AccordionPanel as={Stack} spacing="4" pt="4">
|
||||||
<TableList<Comparison>
|
<HStack>
|
||||||
initialItems={
|
<NumberInput
|
||||||
whatsAppSettings?.startCondition?.comparisons ?? []
|
max={48}
|
||||||
}
|
min={0}
|
||||||
onItemsChange={updateStartConditionComparisons}
|
width="100px"
|
||||||
Item={WhatsAppComparisonItem}
|
label="Session expire timeout:"
|
||||||
ComponentBetweenItems={() => (
|
defaultValue={
|
||||||
<Flex justify="center">
|
whatsAppSettings?.sessionExpiryTimeout
|
||||||
<DropdownList
|
}
|
||||||
currentItem={
|
placeholder={defaultSessionExpiryTimeout.toString()}
|
||||||
whatsAppSettings?.startCondition
|
moreInfoTooltip="A number between 0 and 48 that represents the time in hours after which the session will expire if the user does not interact with the bot. The conversation restarts if the user sends a message after that expiration time."
|
||||||
?.logicalOperator
|
onValueChange={updateSessionExpiryTimeout}
|
||||||
}
|
withVariableButton={false}
|
||||||
onItemSelect={
|
suffix="hours"
|
||||||
updateStartConditionLogicalOperator
|
/>
|
||||||
}
|
</HStack>
|
||||||
items={Object.values(LogicalOperator)}
|
<SwitchWithRelatedSettings
|
||||||
size="sm"
|
label={'Start bot condition'}
|
||||||
/>
|
initialValue={isDefined(
|
||||||
</Flex>
|
whatsAppSettings?.startCondition
|
||||||
)}
|
)}
|
||||||
addLabel="Add a comparison"
|
onCheckChange={updateIsStartConditionEnabled}
|
||||||
/>
|
>
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
</SwitchWithRelatedSettings>
|
||||||
</AccordionPanel>
|
</AccordionPanel>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { saveStateToDatabase } from '@typebot.io/bot-engine/saveStateToDatabase'
|
|||||||
import { restartSession } from '@typebot.io/bot-engine/queries/restartSession'
|
import { restartSession } from '@typebot.io/bot-engine/queries/restartSession'
|
||||||
import { continueBotFlow } from '@typebot.io/bot-engine/continueBotFlow'
|
import { continueBotFlow } from '@typebot.io/bot-engine/continueBotFlow'
|
||||||
import { parseDynamicTheme } from '@typebot.io/bot-engine/parseDynamicTheme'
|
import { parseDynamicTheme } from '@typebot.io/bot-engine/parseDynamicTheme'
|
||||||
|
import { isDefined } from '@typebot.io/lib/utils'
|
||||||
|
|
||||||
export const sendMessage = publicProcedure
|
export const sendMessage = publicProcedure
|
||||||
.meta({
|
.meta({
|
||||||
@@ -30,6 +31,17 @@ export const sendMessage = publicProcedure
|
|||||||
}) => {
|
}) => {
|
||||||
const session = sessionId ? await getSession(sessionId) : null
|
const session = sessionId ? await getSession(sessionId) : null
|
||||||
|
|
||||||
|
const isSessionExpired =
|
||||||
|
session &&
|
||||||
|
isDefined(session.state.expiryTimeout) &&
|
||||||
|
session.updatedAt.getTime() + session.state.expiryTimeout < Date.now()
|
||||||
|
|
||||||
|
if (isSessionExpired)
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'NOT_FOUND',
|
||||||
|
message: 'Session expired. You need to start a new session.',
|
||||||
|
})
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
if (!startParams)
|
if (!startParams)
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { validateRatingReply } from './blocks/inputs/rating/validateRatingReply'
|
|||||||
import { parsePictureChoicesReply } from './blocks/inputs/pictureChoice/parsePictureChoicesReply'
|
import { parsePictureChoicesReply } from './blocks/inputs/pictureChoice/parsePictureChoicesReply'
|
||||||
import { parseVariables } from './variables/parseVariables'
|
import { parseVariables } from './variables/parseVariables'
|
||||||
import { updateVariablesInSession } from './variables/updateVariablesInSession'
|
import { updateVariablesInSession } from './variables/updateVariablesInSession'
|
||||||
|
import { TRPCError } from '@trpc/server'
|
||||||
|
|
||||||
export const continueBotFlow =
|
export const continueBotFlow =
|
||||||
(state: SessionState) =>
|
(state: SessionState) =>
|
||||||
@@ -46,7 +47,11 @@ export const continueBotFlow =
|
|||||||
|
|
||||||
const block = blockIndex >= 0 ? group?.blocks[blockIndex ?? 0] : null
|
const block = blockIndex >= 0 ? group?.blocks[blockIndex ?? 0] : null
|
||||||
|
|
||||||
if (!block || !group) return startBotFlow(state)
|
if (!block || !group)
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'INTERNAL_SERVER_ERROR',
|
||||||
|
message: 'Group / block not found',
|
||||||
|
})
|
||||||
|
|
||||||
if (block.type === LogicBlockType.SET_VARIABLE) {
|
if (block.type === LogicBlockType.SET_VARIABLE) {
|
||||||
const existingVariable = state.typebotsQueue[0].typebot.variables.find(
|
const existingVariable = state.typebotsQueue[0].typebot.variables.find(
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import prisma from '@typebot.io/lib/prisma'
|
import prisma from '@typebot.io/lib/prisma'
|
||||||
import { ChatSession, sessionStateSchema } from '@typebot.io/schemas'
|
import { ChatSession, sessionStateSchema } from '@typebot.io/schemas'
|
||||||
|
|
||||||
export const getSession = async (
|
export const getSession = async (sessionId: string) => {
|
||||||
sessionId: string
|
|
||||||
): Promise<Pick<ChatSession, 'state' | 'id'> | null> => {
|
|
||||||
const session = await prisma.chatSession.findUnique({
|
const session = await prisma.chatSession.findUnique({
|
||||||
where: { id: sessionId },
|
where: { id: sessionId },
|
||||||
select: { id: true, state: true },
|
select: { id: true, state: true, updatedAt: true },
|
||||||
})
|
})
|
||||||
if (!session) return null
|
if (!session) return null
|
||||||
return { ...session, state: sessionStateSchema.parse(session.state) }
|
return { ...session, state: sessionStateSchema.parse(session.state) }
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { continueBotFlow } from '../continueBotFlow'
|
|||||||
import { decrypt } from '@typebot.io/lib/api'
|
import { decrypt } from '@typebot.io/lib/api'
|
||||||
import { saveStateToDatabase } from '../saveStateToDatabase'
|
import { saveStateToDatabase } from '../saveStateToDatabase'
|
||||||
import prisma from '@typebot.io/lib/prisma'
|
import prisma from '@typebot.io/lib/prisma'
|
||||||
|
import { isDefined } from '@typebot.io/lib/utils'
|
||||||
|
|
||||||
export const resumeWhatsAppFlow = async ({
|
export const resumeWhatsAppFlow = async ({
|
||||||
receivedMessage,
|
receivedMessage,
|
||||||
@@ -64,17 +65,23 @@ export const resumeWhatsAppFlow = async ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const resumeResponse = sessionState
|
const isSessionExpired =
|
||||||
? await continueBotFlow(sessionState)(messageContent)
|
session &&
|
||||||
: workspaceId
|
isDefined(session.state.expiryTimeout) &&
|
||||||
? await startWhatsAppSession({
|
session?.updatedAt.getTime() + session.state.expiryTimeout < Date.now()
|
||||||
message: receivedMessage,
|
|
||||||
sessionId,
|
const resumeResponse =
|
||||||
workspaceId,
|
sessionState && !isSessionExpired
|
||||||
credentials: { ...credentials, id: credentialsId as string },
|
? await continueBotFlow(sessionState)(messageContent)
|
||||||
contact,
|
: workspaceId
|
||||||
})
|
? await startWhatsAppSession({
|
||||||
: undefined
|
message: receivedMessage,
|
||||||
|
sessionId,
|
||||||
|
workspaceId,
|
||||||
|
credentials: { ...credentials, id: credentialsId as string },
|
||||||
|
contact,
|
||||||
|
})
|
||||||
|
: undefined
|
||||||
|
|
||||||
if (!resumeResponse) {
|
if (!resumeResponse) {
|
||||||
console.error('Could not find or create session', sessionId)
|
console.error('Could not find or create session', sessionId)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
WhatsAppCredentials,
|
WhatsAppCredentials,
|
||||||
WhatsAppIncomingMessage,
|
WhatsAppIncomingMessage,
|
||||||
|
defaultSessionExpiryTimeout,
|
||||||
} 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 { startSession } from '../startSession'
|
import { startSession } from '../startSession'
|
||||||
@@ -76,14 +77,18 @@ export const startWhatsAppSession = async ({
|
|||||||
userId: undefined,
|
userId: undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const sessionExpiryTimeoutHours =
|
||||||
|
publicTypebot.settings.whatsApp?.sessionExpiryTimeout ??
|
||||||
|
defaultSessionExpiryTimeout
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...session,
|
...session,
|
||||||
newSessionState: {
|
newSessionState: {
|
||||||
...session.newSessionState,
|
...session.newSessionState,
|
||||||
whatsApp: {
|
whatsApp: {
|
||||||
contact,
|
contact,
|
||||||
credentialsId: credentials.id,
|
|
||||||
},
|
},
|
||||||
|
expiryTimeout: sessionExpiryTimeoutHours * 60 * 60 * 1000,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,9 +71,13 @@ const sessionStateSchemaV2 = z.object({
|
|||||||
name: z.string(),
|
name: z.string(),
|
||||||
phoneNumber: z.string(),
|
phoneNumber: z.string(),
|
||||||
}),
|
}),
|
||||||
credentialsId: z.string().optional(),
|
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
|
expiryTimeout: z
|
||||||
|
.number()
|
||||||
|
.min(1)
|
||||||
|
.optional()
|
||||||
|
.describe('Expiry timeout in milliseconds'),
|
||||||
typingEmulation: settingsSchema.shape.typingEmulation.optional(),
|
typingEmulation: settingsSchema.shape.typingEmulation.optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -190,8 +190,16 @@ const startConditionSchema = z.object({
|
|||||||
export const whatsAppSettingsSchema = z.object({
|
export const whatsAppSettingsSchema = z.object({
|
||||||
isEnabled: z.boolean().optional(),
|
isEnabled: z.boolean().optional(),
|
||||||
startCondition: startConditionSchema.optional(),
|
startCondition: startConditionSchema.optional(),
|
||||||
|
sessionExpiryTimeout: z
|
||||||
|
.number()
|
||||||
|
.max(48)
|
||||||
|
.min(0.01)
|
||||||
|
.optional()
|
||||||
|
.describe('Expiration delay in hours after latest interaction'),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const defaultSessionExpiryTimeout = 12
|
||||||
|
|
||||||
export type WhatsAppIncomingMessage = z.infer<typeof incomingMessageSchema>
|
export type WhatsAppIncomingMessage = z.infer<typeof incomingMessageSchema>
|
||||||
export type WhatsAppSendingMessage = z.infer<typeof sendingMessageSchema>
|
export type WhatsAppSendingMessage = z.infer<typeof sendingMessageSchema>
|
||||||
export type WhatsAppCredentials = z.infer<typeof whatsAppCredentialsSchema>
|
export type WhatsAppCredentials = z.infer<typeof whatsAppCredentialsSchema>
|
||||||
|
|||||||
Reference in New Issue
Block a user