From 4f953ac272b1e2a826776f9d4cefcce63757abf1 Mon Sep 17 00:00:00 2001 From: Baptiste Arnaud Date: Fri, 22 Sep 2023 17:12:15 +0200 Subject: [PATCH] :sparkles: (whatsapp) Add custom session expiration (#842) ### 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. --- .../src/components/inputs/NumberInput.tsx | 26 ++-- .../src/components/inputs/SwitchWithLabel.tsx | 4 +- .../embed/components/EmbedUploadContent.tsx | 17 ++- .../modals/WhatsAppModal/WhatsAppModal.tsx | 115 ++++++++++++++---- .../src/features/chat/api/sendMessage.ts | 12 ++ packages/bot-engine/continueBotFlow.ts | 7 +- packages/bot-engine/queries/getSession.ts | 6 +- .../bot-engine/whatsapp/resumeWhatsAppFlow.ts | 29 +++-- .../whatsapp/startWhatsAppSession.ts | 7 +- .../schemas/features/chat/sessionState.ts | 6 +- packages/schemas/features/whatsapp.ts | 8 ++ 11 files changed, 175 insertions(+), 62 deletions(-) diff --git a/apps/builder/src/components/inputs/NumberInput.tsx b/apps/builder/src/components/inputs/NumberInput.tsx index 9d598f14f..5cc588da1 100644 --- a/apps/builder/src/components/inputs/NumberInput.tsx +++ b/apps/builder/src/components/inputs/NumberInput.tsx @@ -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 = { moreInfoTooltip?: string isRequired?: boolean direction?: 'row' | 'column' + suffix?: string onValueChange: (value?: Value) => void } & Omit @@ -41,6 +43,7 @@ export const NumberInput = ({ moreInfoTooltip, isRequired, direction, + suffix, ...props }: Props) => { const [value, setValue] = useState(defaultValue?.toString() ?? '') @@ -99,24 +102,27 @@ export const NumberInput = ({ isRequired={isRequired} justifyContent="space-between" width={label ? 'full' : 'auto'} - spacing={0} + spacing={direction === 'column' ? 2 : 3} > {label && ( - + {label}{' '} {moreInfoTooltip && ( {moreInfoTooltip} )} )} - {withVariableButton ?? true ? ( - - {Input} - - - ) : ( - Input - )} + + {withVariableButton ?? true ? ( + + {Input} + + + ) : ( + Input + )} + {suffix ? {suffix} : null} + ) } diff --git a/apps/builder/src/components/inputs/SwitchWithLabel.tsx b/apps/builder/src/components/inputs/SwitchWithLabel.tsx index b55ddeced..0a50c5be0 100644 --- a/apps/builder/src/components/inputs/SwitchWithLabel.tsx +++ b/apps/builder/src/components/inputs/SwitchWithLabel.tsx @@ -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 @@ -29,7 +29,7 @@ export const SwitchWithLabel = ({ const handleChange = () => { setIsChecked(!isChecked) - onCheckChange(!isChecked) + if (onCheckChange) onCheckChange(!isChecked) } return ( diff --git a/apps/builder/src/features/blocks/bubbles/embed/components/EmbedUploadContent.tsx b/apps/builder/src/features/blocks/bubbles/embed/components/EmbedUploadContent.tsx index f7dfcc0c5..3a5904f20 100644 --- a/apps/builder/src/features/blocks/bubbles/embed/components/EmbedUploadContent.tsx +++ b/apps/builder/src/features/blocks/bubbles/embed/components/EmbedUploadContent.tsx @@ -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) => { - - - {scopedT('numberInput.unit')} - + ) } diff --git a/apps/builder/src/features/publish/components/embeds/modals/WhatsAppModal/WhatsAppModal.tsx b/apps/builder/src/features/publish/components/embeds/modals/WhatsAppModal/WhatsAppModal.tsx index 065e49db3..3eeac1c0c 100644 --- a/apps/builder/src/features/publish/components/embeds/modals/WhatsAppModal/WhatsAppModal.tsx +++ b/apps/builder/src/features/publish/components/embeds/modals/WhatsAppModal/WhatsAppModal.tsx @@ -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 ( @@ -166,33 +210,58 @@ export const WhatsAppModal = ({ isOpen, onClose }: ModalProps): JSX.Element => { - Start flow only if + Configure integration - - initialItems={ - whatsAppSettings?.startCondition?.comparisons ?? [] - } - onItemsChange={updateStartConditionComparisons} - Item={WhatsAppComparisonItem} - ComponentBetweenItems={() => ( - - - + + + + + onCheckChange={updateIsStartConditionEnabled} + > + + initialItems={ + whatsAppSettings?.startCondition?.comparisons ?? + [] + } + onItemsChange={updateStartConditionComparisons} + Item={WhatsAppComparisonItem} + ComponentBetweenItems={() => ( + + + + )} + addLabel="Add a comparison" + /> + diff --git a/apps/viewer/src/features/chat/api/sendMessage.ts b/apps/viewer/src/features/chat/api/sendMessage.ts index 375ad04a4..bf1b21e12 100644 --- a/apps/viewer/src/features/chat/api/sendMessage.ts +++ b/apps/viewer/src/features/chat/api/sendMessage.ts @@ -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({ diff --git a/packages/bot-engine/continueBotFlow.ts b/packages/bot-engine/continueBotFlow.ts index 3800edb05..015affb06 100644 --- a/packages/bot-engine/continueBotFlow.ts +++ b/packages/bot-engine/continueBotFlow.ts @@ -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( diff --git a/packages/bot-engine/queries/getSession.ts b/packages/bot-engine/queries/getSession.ts index c7a00bc9c..18167ad1a 100644 --- a/packages/bot-engine/queries/getSession.ts +++ b/packages/bot-engine/queries/getSession.ts @@ -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 | 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) } diff --git a/packages/bot-engine/whatsapp/resumeWhatsAppFlow.ts b/packages/bot-engine/whatsapp/resumeWhatsAppFlow.ts index 3403413e6..a25f25785 100644 --- a/packages/bot-engine/whatsapp/resumeWhatsAppFlow.ts +++ b/packages/bot-engine/whatsapp/resumeWhatsAppFlow.ts @@ -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) diff --git a/packages/bot-engine/whatsapp/startWhatsAppSession.ts b/packages/bot-engine/whatsapp/startWhatsAppSession.ts index 07903e19a..4656e321f 100644 --- a/packages/bot-engine/whatsapp/startWhatsAppSession.ts +++ b/packages/bot-engine/whatsapp/startWhatsAppSession.ts @@ -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, }, } } diff --git a/packages/schemas/features/chat/sessionState.ts b/packages/schemas/features/chat/sessionState.ts index 4f078a203..4234ad647 100644 --- a/packages/schemas/features/chat/sessionState.ts +++ b/packages/schemas/features/chat/sessionState.ts @@ -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(), }) diff --git a/packages/schemas/features/whatsapp.ts b/packages/schemas/features/whatsapp.ts index 74dff104b..15487b428 100644 --- a/packages/schemas/features/whatsapp.ts +++ b/packages/schemas/features/whatsapp.ts @@ -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 export type WhatsAppSendingMessage = z.infer export type WhatsAppCredentials = z.infer