2
0

(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:
Baptiste Arnaud
2023-09-22 17:12:15 +02:00
committed by GitHub
parent 4cfb45e2a3
commit 4f953ac272
11 changed files with 175 additions and 62 deletions

View File

@@ -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>
) )
} }

View File

@@ -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 (

View File

@@ -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>
) )
} }

View File

@@ -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>

View File

@@ -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({

View File

@@ -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(

View File

@@ -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) }

View File

@@ -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)

View File

@@ -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,
}, },
} }
} }

View File

@@ -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(),
}) })

View File

@@ -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>