Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ (whatsapp) Add custom session expiration #842

Merged
merged 3 commits into from
Sep 22, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 16 additions & 10 deletions apps/builder/src/components/inputs/NumberInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'>

Expand All @@ -41,6 +43,7 @@ export const NumberInput = <HasVariable extends boolean>({
moreInfoTooltip,
isRequired,
direction,
suffix,
...props
}: Props<HasVariable>) => {
const [value, setValue] = useState(defaultValue?.toString() ?? '')
Expand Down Expand Up @@ -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}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The spacing between the form label and the input field has been made dynamic based on the direction prop. If direction is "column", the spacing is 2; otherwise, it's 3. This allows for more flexible layout configurations.

- spacing={direction === 'column' ? 2 : 3}
+ spacing={direction === 'column' ? 2 : 0}

This change will ensure that the spacing remains consistent with the previous version when the direction is not 'column'.

>
{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>
)
}
4 changes: 2 additions & 2 deletions apps/builder/src/components/inputs/SwitchWithLabel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'>

Expand All @@ -29,7 +29,7 @@ export const SwitchWithLabel = ({

const handleChange = () => {
setIsChecked(!isChecked)
onCheckChange(!isChecked)
if (onCheckChange) onCheckChange(!isChecked)
}

return (
Expand Down
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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>
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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,
},
},
},
},
})
}
baptisteArno marked this conversation as resolved.
Show resolved Hide resolved

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 />
Expand Down Expand Up @@ -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>
baptisteArno marked this conversation as resolved.
Show resolved Hide resolved
</AccordionPanel>
</AccordionItem>
</Accordion>
Expand Down
12 changes: 12 additions & 0 deletions apps/viewer/src/features/chat/api/sendMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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()
Comment on lines +34 to +37
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider adding null checks for session.state and session.updatedAt to avoid potential null reference errors.

- const isSessionExpired =
-   session &&
-   isDefined(session.state.expiryTimeout) &&
-   session.updatedAt.getTime() + session.state.expiryTimeout < Date.now()
+ const isSessionExpired = 
+   session && 
+   session.state && 
+   session.updatedAt && 
+   isDefined(session.state.expiryTimeout) && 
+   session.updatedAt.getTime() + session.state.expiryTimeout * 60 * 60 * 1000 < Date.now()


if (isSessionExpired)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Session expired. You need to start a new session.',
})
Comment on lines 31 to +43
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The logic for checking session expiration seems correct, but it's important to ensure that the expiryTimeout is stored in milliseconds since it's being compared with Date.now(), which returns the current time in milliseconds. If expiryTimeout is stored in hours as per the PR summary, you need to convert it to milliseconds before comparing.

- session.updatedAt.getTime() + session.state.expiryTimeout < Date.now()
+ session.updatedAt.getTime() + session.state.expiryTimeout * 60 * 60 * 1000 < Date.now()


if (!session) {
if (!startParams)
throw new TRPCError({
Expand Down
7 changes: 6 additions & 1 deletion packages/bot-engine/continueBotFlow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand All @@ -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',
})
baptisteArno marked this conversation as resolved.
Show resolved Hide resolved

if (block.type === LogicBlockType.SET_VARIABLE) {
const existingVariable = state.typebotsQueue[0].typebot.variables.find(
Expand Down
6 changes: 2 additions & 4 deletions packages/bot-engine/queries/getSession.ts
Original file line number Diff line number Diff line change
@@ -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) }
baptisteArno marked this conversation as resolved.
Show resolved Hide resolved
Expand Down
29 changes: 18 additions & 11 deletions packages/bot-engine/whatsapp/resumeWhatsAppFlow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Comment on lines +68 to +71
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The session expiration check is not correctly implemented. The expiryTimeout is supposed to represent hours, but it's being compared directly with milliseconds from Date.now(). You need to convert the expiryTimeout to milliseconds before performing the comparison.

- session?.updatedAt.getTime() + session.state.expiryTimeout < Date.now()
+ session?.updatedAt.getTime() + session.state.expiryTimeout * 60 * 60 * 1000 < 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)
Expand Down
7 changes: 6 additions & 1 deletion packages/bot-engine/whatsapp/startWhatsAppSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,
},
}
}
Expand Down
6 changes: 5 additions & 1 deletion packages/schemas/features/chat/sessionState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
})

Expand Down
Loading