✨ (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,
|
||||
FormLabel,
|
||||
Stack,
|
||||
Text,
|
||||
} from '@chakra-ui/react'
|
||||
import { Variable, VariableString } from '@typebot.io/schemas'
|
||||
import { useEffect, useState } from 'react'
|
||||
@ -29,6 +30,7 @@ type Props<HasVariable extends boolean> = {
|
||||
moreInfoTooltip?: string
|
||||
isRequired?: boolean
|
||||
direction?: 'row' | 'column'
|
||||
suffix?: string
|
||||
onValueChange: (value?: Value<HasVariable>) => void
|
||||
} & Omit<NumberInputProps, 'defaultValue' | 'value' | 'onChange' | 'isRequired'>
|
||||
|
||||
@ -41,6 +43,7 @@ export const NumberInput = <HasVariable extends boolean>({
|
||||
moreInfoTooltip,
|
||||
isRequired,
|
||||
direction,
|
||||
suffix,
|
||||
...props
|
||||
}: Props<HasVariable>) => {
|
||||
const [value, setValue] = useState(defaultValue?.toString() ?? '')
|
||||
@ -99,24 +102,27 @@ export const NumberInput = <HasVariable extends boolean>({
|
||||
isRequired={isRequired}
|
||||
justifyContent="space-between"
|
||||
width={label ? 'full' : 'auto'}
|
||||
spacing={0}
|
||||
spacing={direction === 'column' ? 2 : 3}
|
||||
>
|
||||
{label && (
|
||||
<FormLabel mb="2" flexShrink={0}>
|
||||
<FormLabel mb="0" mr="0" flexShrink={0}>
|
||||
{label}{' '}
|
||||
{moreInfoTooltip && (
|
||||
<MoreInfoTooltip>{moreInfoTooltip}</MoreInfoTooltip>
|
||||
)}
|
||||
</FormLabel>
|
||||
)}
|
||||
{withVariableButton ?? true ? (
|
||||
<HStack spacing={0}>
|
||||
{Input}
|
||||
<VariablesButton onSelectVariable={handleVariableSelected} />
|
||||
</HStack>
|
||||
) : (
|
||||
Input
|
||||
)}
|
||||
<HStack>
|
||||
{withVariableButton ?? true ? (
|
||||
<HStack spacing="0">
|
||||
{Input}
|
||||
<VariablesButton onSelectVariable={handleVariableSelected} />
|
||||
</HStack>
|
||||
) : (
|
||||
Input
|
||||
)}
|
||||
{suffix ? <Text>{suffix}</Text> : null}
|
||||
</HStack>
|
||||
</FormControl>
|
||||
)
|
||||
}
|
||||
|
@ -13,7 +13,7 @@ export type SwitchWithLabelProps = {
|
||||
label: string
|
||||
initialValue: boolean
|
||||
moreInfoContent?: string
|
||||
onCheckChange: (isChecked: boolean) => void
|
||||
onCheckChange?: (isChecked: boolean) => void
|
||||
justifyContent?: FormControlProps['justifyContent']
|
||||
} & Omit<SwitchProps, 'value' | 'justifyContent'>
|
||||
|
||||
@ -29,7 +29,7 @@ export const SwitchWithLabel = ({
|
||||
|
||||
const handleChange = () => {
|
||||
setIsChecked(!isChecked)
|
||||
onCheckChange(!isChecked)
|
||||
if (onCheckChange) onCheckChange(!isChecked)
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -1,5 +1,5 @@
|
||||
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 { sanitizeUrl } from '@typebot.io/lib'
|
||||
import { useScopedI18n } from '@/locales'
|
||||
@ -34,14 +34,13 @@ export const EmbedUploadContent = ({ content, onSubmit }: Props) => {
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<HStack>
|
||||
<NumberInput
|
||||
label="Height:"
|
||||
defaultValue={content?.height}
|
||||
onValueChange={handleHeightChange}
|
||||
/>
|
||||
<Text>{scopedT('numberInput.unit')}</Text>
|
||||
</HStack>
|
||||
<NumberInput
|
||||
label="Height:"
|
||||
defaultValue={content?.height}
|
||||
onValueChange={handleHeightChange}
|
||||
suffix={scopedT('numberInput.unit')}
|
||||
width="150px"
|
||||
/>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
@ -35,6 +35,10 @@ import { Comparison, LogicalOperator } from '@typebot.io/schemas'
|
||||
import { DropdownList } from '@/components/DropdownList'
|
||||
import { WhatsAppComparisonItem } from './WhatsAppComparisonItem'
|
||||
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 => {
|
||||
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 (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="xl">
|
||||
<ModalOverlay />
|
||||
@ -166,33 +210,58 @@ export const WhatsAppModal = ({ isOpen, onClose }: ModalProps): JSX.Element => {
|
||||
<Accordion allowToggle>
|
||||
<AccordionItem>
|
||||
<AccordionButton justifyContent="space-between">
|
||||
Start flow only if
|
||||
Configure integration
|
||||
<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>
|
||||
<HStack>
|
||||
<NumberInput
|
||||
max={48}
|
||||
min={0}
|
||||
width="100px"
|
||||
label="Session expire timeout:"
|
||||
defaultValue={
|
||||
whatsAppSettings?.sessionExpiryTimeout
|
||||
}
|
||||
placeholder={defaultSessionExpiryTimeout.toString()}
|
||||
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."
|
||||
onValueChange={updateSessionExpiryTimeout}
|
||||
withVariableButton={false}
|
||||
suffix="hours"
|
||||
/>
|
||||
</HStack>
|
||||
<SwitchWithRelatedSettings
|
||||
label={'Start bot condition'}
|
||||
initialValue={isDefined(
|
||||
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>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
@ -10,6 +10,7 @@ import { saveStateToDatabase } from '@typebot.io/bot-engine/saveStateToDatabase'
|
||||
import { restartSession } from '@typebot.io/bot-engine/queries/restartSession'
|
||||
import { continueBotFlow } from '@typebot.io/bot-engine/continueBotFlow'
|
||||
import { parseDynamicTheme } from '@typebot.io/bot-engine/parseDynamicTheme'
|
||||
import { isDefined } from '@typebot.io/lib/utils'
|
||||
|
||||
export const sendMessage = publicProcedure
|
||||
.meta({
|
||||
@ -30,6 +31,17 @@ export const sendMessage = publicProcedure
|
||||
}) => {
|
||||
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 (!startParams)
|
||||
throw new TRPCError({
|
||||
|
@ -29,6 +29,7 @@ import { validateRatingReply } from './blocks/inputs/rating/validateRatingReply'
|
||||
import { parsePictureChoicesReply } from './blocks/inputs/pictureChoice/parsePictureChoicesReply'
|
||||
import { parseVariables } from './variables/parseVariables'
|
||||
import { updateVariablesInSession } from './variables/updateVariablesInSession'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
|
||||
export const continueBotFlow =
|
||||
(state: SessionState) =>
|
||||
@ -46,7 +47,11 @@ export const continueBotFlow =
|
||||
|
||||
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) {
|
||||
const existingVariable = state.typebotsQueue[0].typebot.variables.find(
|
||||
|
@ -1,12 +1,10 @@
|
||||
import prisma from '@typebot.io/lib/prisma'
|
||||
import { ChatSession, sessionStateSchema } from '@typebot.io/schemas'
|
||||
|
||||
export const getSession = async (
|
||||
sessionId: string
|
||||
): Promise<Pick<ChatSession, 'state' | 'id'> | null> => {
|
||||
export const getSession = async (sessionId: string) => {
|
||||
const session = await prisma.chatSession.findUnique({
|
||||
where: { id: sessionId },
|
||||
select: { id: true, state: true },
|
||||
select: { id: true, state: true, updatedAt: true },
|
||||
})
|
||||
if (!session) return null
|
||||
return { ...session, state: sessionStateSchema.parse(session.state) }
|
||||
|
@ -11,6 +11,7 @@ import { continueBotFlow } from '../continueBotFlow'
|
||||
import { decrypt } from '@typebot.io/lib/api'
|
||||
import { saveStateToDatabase } from '../saveStateToDatabase'
|
||||
import prisma from '@typebot.io/lib/prisma'
|
||||
import { isDefined } from '@typebot.io/lib/utils'
|
||||
|
||||
export const resumeWhatsAppFlow = async ({
|
||||
receivedMessage,
|
||||
@ -64,17 +65,23 @@ export const resumeWhatsAppFlow = async ({
|
||||
}
|
||||
}
|
||||
|
||||
const resumeResponse = sessionState
|
||||
? await continueBotFlow(sessionState)(messageContent)
|
||||
: workspaceId
|
||||
? await startWhatsAppSession({
|
||||
message: receivedMessage,
|
||||
sessionId,
|
||||
workspaceId,
|
||||
credentials: { ...credentials, id: credentialsId as string },
|
||||
contact,
|
||||
})
|
||||
: undefined
|
||||
const isSessionExpired =
|
||||
session &&
|
||||
isDefined(session.state.expiryTimeout) &&
|
||||
session?.updatedAt.getTime() + session.state.expiryTimeout < Date.now()
|
||||
|
||||
const resumeResponse =
|
||||
sessionState && !isSessionExpired
|
||||
? await continueBotFlow(sessionState)(messageContent)
|
||||
: workspaceId
|
||||
? await startWhatsAppSession({
|
||||
message: receivedMessage,
|
||||
sessionId,
|
||||
workspaceId,
|
||||
credentials: { ...credentials, id: credentialsId as string },
|
||||
contact,
|
||||
})
|
||||
: undefined
|
||||
|
||||
if (!resumeResponse) {
|
||||
console.error('Could not find or create session', sessionId)
|
||||
|
@ -11,6 +11,7 @@ import {
|
||||
import {
|
||||
WhatsAppCredentials,
|
||||
WhatsAppIncomingMessage,
|
||||
defaultSessionExpiryTimeout,
|
||||
} from '@typebot.io/schemas/features/whatsapp'
|
||||
import { isNotDefined } from '@typebot.io/lib/utils'
|
||||
import { startSession } from '../startSession'
|
||||
@ -76,14 +77,18 @@ export const startWhatsAppSession = async ({
|
||||
userId: undefined,
|
||||
})
|
||||
|
||||
const sessionExpiryTimeoutHours =
|
||||
publicTypebot.settings.whatsApp?.sessionExpiryTimeout ??
|
||||
defaultSessionExpiryTimeout
|
||||
|
||||
return {
|
||||
...session,
|
||||
newSessionState: {
|
||||
...session.newSessionState,
|
||||
whatsApp: {
|
||||
contact,
|
||||
credentialsId: credentials.id,
|
||||
},
|
||||
expiryTimeout: sessionExpiryTimeoutHours * 60 * 60 * 1000,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -71,9 +71,13 @@ const sessionStateSchemaV2 = z.object({
|
||||
name: z.string(),
|
||||
phoneNumber: z.string(),
|
||||
}),
|
||||
credentialsId: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
expiryTimeout: z
|
||||
.number()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe('Expiry timeout in milliseconds'),
|
||||
typingEmulation: settingsSchema.shape.typingEmulation.optional(),
|
||||
})
|
||||
|
||||
|
@ -190,8 +190,16 @@ const startConditionSchema = z.object({
|
||||
export const whatsAppSettingsSchema = z.object({
|
||||
isEnabled: z.boolean().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 WhatsAppSendingMessage = z.infer<typeof sendingMessageSchema>
|
||||
export type WhatsAppCredentials = z.infer<typeof whatsAppCredentialsSchema>
|
||||
|
Reference in New Issue
Block a user