diff --git a/api/src/chat/controllers/context-var.controller.spec.ts b/api/src/chat/controllers/context-var.controller.spec.ts index 77aadde6..a06eb4a0 100644 --- a/api/src/chat/controllers/context-var.controller.spec.ts +++ b/api/src/chat/controllers/context-var.controller.spec.ts @@ -100,6 +100,7 @@ describe('ContextVarController', () => { const contextVarCreateDto: ContextVarCreateDto = { label: 'contextVarLabel2', name: 'test_add', + permanent: false, }; const result = await contextVarController.create(contextVarCreateDto); diff --git a/api/src/chat/dto/context-var.dto.ts b/api/src/chat/dto/context-var.dto.ts index 715efd89..7bed0ac3 100644 --- a/api/src/chat/dto/context-var.dto.ts +++ b/api/src/chat/dto/context-var.dto.ts @@ -8,7 +8,7 @@ */ import { ApiProperty, PartialType } from '@nestjs/swagger'; -import { IsNotEmpty, IsString } from 'class-validator'; +import { IsBoolean, IsNotEmpty, IsOptional, IsString } from 'class-validator'; export class ContextVarCreateDto { @ApiProperty({ description: 'Context var label', type: String }) @@ -20,6 +20,11 @@ export class ContextVarCreateDto { @IsNotEmpty() @IsString() name: string; + + @ApiProperty({ description: 'Is context var permanent', type: Boolean }) + @IsOptional() + @IsBoolean() + permanent?: boolean; } export class ContextVarUpdateDto extends PartialType(ContextVarCreateDto) {} diff --git a/api/src/chat/schemas/context-var.schema.ts b/api/src/chat/schemas/context-var.schema.ts index 4757391c..9530f8d7 100644 --- a/api/src/chat/schemas/context-var.schema.ts +++ b/api/src/chat/schemas/context-var.schema.ts @@ -28,6 +28,17 @@ export class ContextVar extends BaseSchema { match: /^[a-z_0-9]+$/, }) name: string; + + /** + * The permanent attribute allows the chatbot to know where to store the context variable. + * If the context variable is not permanent, it will be stored in the converation context, which is temporary. + * If the context variable is permanent, it will be stored in the subscriber context, which is permanent. + */ + @Prop({ + type: Boolean, + default: false, + }) + permanent?: boolean; } export const ContextVarModel: ModelDefinition = { diff --git a/api/src/chat/schemas/subscriber.schema.ts b/api/src/chat/schemas/subscriber.schema.ts index 1cee129d..f0e451b4 100644 --- a/api/src/chat/schemas/subscriber.schema.ts +++ b/api/src/chat/schemas/subscriber.schema.ts @@ -19,6 +19,7 @@ import { TFilterPopulateFields } from '@/utils/types/filter.types'; import { Label } from './label.schema'; import { ChannelData } from './types/channel'; +import { SubscriberContext } from './types/subscriberContext'; @Schema({ timestamps: true }) export class SubscriberStub extends BaseSchema { @@ -107,6 +108,12 @@ export class SubscriberStub extends BaseSchema { default: null, }) avatar?: unknown; + + @Prop({ + type: Object, + default: { vars: {} }, //TODO: add this to the migration + }) + context?: SubscriberContext; } @Schema({ timestamps: true }) diff --git a/api/src/chat/schemas/types/subscriberContext.ts b/api/src/chat/schemas/types/subscriberContext.ts new file mode 100644 index 00000000..18108d3b --- /dev/null +++ b/api/src/chat/schemas/types/subscriberContext.ts @@ -0,0 +1,3 @@ +export interface SubscriberContext { + [key: string]: any; +} diff --git a/api/src/chat/services/block.service.spec.ts b/api/src/chat/services/block.service.spec.ts index 3f2104e1..c6efe85a 100644 --- a/api/src/chat/services/block.service.spec.ts +++ b/api/src/chat/services/block.service.spec.ts @@ -52,6 +52,7 @@ import { contextBlankInstance, contextEmailVarInstance, contextGetStartedInstance, + subscriberContextBlankInstance, } from '@/utils/test/mocks/conversation'; import { nlpEntitiesGreeting } from '@/utils/test/mocks/nlp'; import { @@ -69,6 +70,7 @@ import { LabelModel } from '../schemas/label.schema'; import { FileType } from '../schemas/types/attachment'; import { Context } from '../schemas/types/context'; import { PayloadType, StdOutgoingListMessage } from '../schemas/types/message'; +import { SubscriberContext } from '../schemas/types/subscriberContext'; describe('BlockService', () => { let blockRepository: BlockRepository; @@ -436,6 +438,7 @@ describe('BlockService', () => { ...contextBlankInstance, skip: { [blockProductListMock.id]: 0 }, }, + subscriberContextBlankInstance, false, 'conv_id', ); @@ -469,6 +472,7 @@ describe('BlockService', () => { ...contextBlankInstance, skip: { [blockProductListMock.id]: 2 }, }, + subscriberContextBlankInstance, false, 'conv_id', ); @@ -513,9 +517,20 @@ describe('BlockService', () => { skip: { '1': 0 }, attempt: 0, }; + const subscriberContext: SubscriberContext = { + ...subscriberContextBlankInstance, + vars: { + phone: '123456789', + }, + }; it('should process empty text', () => { - const result = blockService.processText('', context, settings); + const result = blockService.processText( + '', + context, + subscriberContext, + settings, + ); expect(result).toEqual(''); }); @@ -524,6 +539,7 @@ describe('BlockService', () => { const result = blockService.processText( translation.en, context, + subscriberContext, settings, ); expect(result).toEqual(translation.fr); @@ -533,6 +549,7 @@ describe('BlockService', () => { const result = blockService.processText( '{context.user.first_name} {context.user.last_name}, email : {context.vars.email}', contextEmailVarInstance, + subscriberContext, settings, ); expect(result).toEqual('John Doe, email : email@example.com'); @@ -540,17 +557,19 @@ describe('BlockService', () => { it('should process text replacements with context vars', () => { const result = blockService.processText( - '{context.user.first_name} {context.user.last_name}, email : {context.vars.email}', + '{context.user.first_name} {context.user.last_name}, phone : {context.vars.phone}', contextEmailVarInstance, + subscriberContext, settings, ); - expect(result).toEqual('John Doe, email : email@example.com'); + expect(result).toEqual('John Doe, phone : 123456789'); }); it('should process text replacements with settings contact infos', () => { const result = blockService.processText( 'Trying the settings : the name of company is <<{contact.company_name}>>', contextBlankInstance, + subscriberContext, settings, ); expect(result).toEqual( diff --git a/api/src/chat/services/block.service.ts b/api/src/chat/services/block.service.ts index 5f228e2a..f1163a9a 100644 --- a/api/src/chat/services/block.service.ts +++ b/api/src/chat/services/block.service.ts @@ -34,6 +34,7 @@ import { } from '../schemas/types/message'; import { NlpPattern, Pattern, PayloadPattern } from '../schemas/types/pattern'; import { Payload, StdQuickReply } from '../schemas/types/quick-reply'; +import { SubscriberContext } from '../schemas/types/subscriberContext'; @Injectable() export class BlockService extends BaseService { @@ -300,22 +301,19 @@ export class BlockService extends BaseService { processTokenReplacements( text: string, context: Context, + subscriberContext: SubscriberContext, settings: Settings, ): string { + const vars = { ...subscriberContext.vars, ...context.vars }; // Replace context tokens with their values - Object.keys(context.vars || {}).forEach((key) => { - if ( - typeof context.vars[key] === 'string' && - context.vars[key].indexOf(':') !== -1 - ) { - const tmp = context.vars[key].split(':'); - context.vars[key] = tmp[1]; + Object.keys(vars).forEach((key) => { + if (typeof vars[key] === 'string' && vars[key].indexOf(':') !== -1) { + const tmp = vars[key].split(':'); + vars[key] = tmp[1]; } text = text.replace( '{context.vars.' + key + '}', - typeof context.vars[key] === 'string' - ? context.vars[key] - : JSON.stringify(context.vars[key]), + typeof vars[key] === 'string' ? vars[key] : JSON.stringify(vars[key]), ); }); @@ -367,14 +365,24 @@ export class BlockService extends BaseService { * * @returns The text message translated and tokens being replaces with values */ - processText(text: string, context: Context, settings: Settings): string { + processText( + text: string, + context: Context, + subscriberContext: SubscriberContext, + settings: Settings, + ): string { // Translate text = this.i18n.t(text, { lang: context.user.language, defaultValue: text, }); // Replace context tokens - text = this.processTokenReplacements(text, context, settings); + text = this.processTokenReplacements( + text, + context, + subscriberContext, + settings, + ); return text; } @@ -421,6 +429,7 @@ export class BlockService extends BaseService { async processMessage( block: Block | BlockFull, context: Context, + subscriberContext: SubscriberContext, fallback = false, conversationId?: string, ): Promise { @@ -438,6 +447,7 @@ export class BlockService extends BaseService { const text = this.processText( this.getRandom(blockMessage), context, + subscriberContext, settings, ); const envelope: StdOutgoingEnvelope = { @@ -454,12 +464,22 @@ export class BlockService extends BaseService { const envelope: StdOutgoingEnvelope = { format: OutgoingMessageFormat.quickReplies, message: { - text: this.processText(blockMessage.text, context, settings), + text: this.processText( + blockMessage.text, + context, + subscriberContext, + settings, + ), quickReplies: blockMessage.quickReplies.map((qr: StdQuickReply) => { return qr.title ? { ...qr, - title: this.processText(qr.title, context, settings), + title: this.processText( + qr.title, + context, + subscriberContext, + settings, + ), } : qr; }), @@ -474,12 +494,22 @@ export class BlockService extends BaseService { const envelope: StdOutgoingEnvelope = { format: OutgoingMessageFormat.buttons, message: { - text: this.processText(blockMessage.text, context, settings), + text: this.processText( + blockMessage.text, + context, + subscriberContext, + settings, + ), buttons: blockMessage.buttons.map((btn) => { return btn.title ? { ...btn, - title: this.processText(btn.title, context, settings), + title: this.processText( + btn.title, + context, + subscriberContext, + settings, + ), } : btn; }), diff --git a/api/src/chat/services/bot.service.spec.ts b/api/src/chat/services/bot.service.spec.ts index 26113d63..1d3fe530 100644 --- a/api/src/chat/services/bot.service.spec.ts +++ b/api/src/chat/services/bot.service.spec.ts @@ -62,15 +62,18 @@ import { CategoryRepository } from './../repositories/category.repository'; import { BlockService } from './block.service'; import { BotService } from './bot.service'; import { CategoryService } from './category.service'; +import { ContextVarService } from './context-var.service'; import { ConversationService } from './conversation.service'; import { MessageService } from './message.service'; import { SubscriberService } from './subscriber.service'; import { BlockRepository } from '../repositories/block.repository'; +import { ContextVarRepository } from '../repositories/context-var.repository'; import { ConversationRepository } from '../repositories/conversation.repository'; import { MessageRepository } from '../repositories/message.repository'; import { SubscriberRepository } from '../repositories/subscriber.repository'; import { BlockFull, BlockModel } from '../schemas/block.schema'; import { CategoryModel } from '../schemas/category.schema'; +import { ContextVarModel } from '../schemas/context-var.schema'; import { Conversation, ConversationFull, @@ -110,6 +113,7 @@ describe('BlockService', () => { NlpEntityModel, NlpSampleEntityModel, NlpSampleModel, + ContextVarModel, LanguageModel, ]), ], @@ -148,6 +152,8 @@ describe('BlockService', () => { NlpSampleEntityService, NlpSampleService, NlpService, + ContextVarService, + ContextVarRepository, LanguageService, { provide: PluginService, diff --git a/api/src/chat/services/bot.service.ts b/api/src/chat/services/bot.service.ts index bdd07650..01c4b635 100644 --- a/api/src/chat/services/bot.service.ts +++ b/api/src/chat/services/bot.service.ts @@ -70,10 +70,12 @@ export class BotService { event.getSenderForeignId(), ); // Process message : Replace tokens with context data and then send the message + const recipient = event.getSender(); const envelope: StdOutgoingEnvelope = await this.blockService.processMessage( block, context, + recipient.context, fallback, conservationId, ); @@ -87,7 +89,6 @@ export class BotService { this.eventEmitter.emit('hook:stats:entry', 'all_messages', 'All Messages'); // Trigger sent message event - const recipient = event.getSender(); const sentMessage: MessageCreateDto = { mid: response && 'mid' in response ? response.mid : '', message: envelope.message, diff --git a/api/src/chat/services/context-var.service.ts b/api/src/chat/services/context-var.service.ts index a347bb9b..99356b2e 100644 --- a/api/src/chat/services/context-var.service.ts +++ b/api/src/chat/services/context-var.service.ts @@ -12,6 +12,7 @@ import { Injectable } from '@nestjs/common'; import { BaseService } from '@/utils/generics/base-service'; import { ContextVarRepository } from '../repositories/context-var.repository'; +import { Block, BlockFull } from '../schemas/block.schema'; import { ContextVar } from '../schemas/context-var.schema'; @Injectable() @@ -19,4 +20,22 @@ export class ContextVarService extends BaseService { constructor(readonly repository: ContextVarRepository) { super(repository); } + + /** + * Retrieves a mapping of context variable names to their corresponding `ContextVar` objects for a given block. + * + * @param {Block | BlockFull} block - The block containing the capture variables to retrieve context variables for. + * @returns {Promise>} A promise that resolves to a record mapping context variable names to `ContextVar` objects. + */ + async getContextVarsByBlock( + block: Block | BlockFull, + ): Promise> { + const vars = await this.find({ + name: { $in: block.capture_vars.map(({ context_var }) => context_var) }, + }); + return vars.reduce((acc, cv) => { + acc[cv.name] = cv; + return acc; + }, {}); + } } diff --git a/api/src/chat/services/conversation.service.ts b/api/src/chat/services/conversation.service.ts index ecf45fc5..dae0eea8 100644 --- a/api/src/chat/services/conversation.service.ts +++ b/api/src/chat/services/conversation.service.ts @@ -7,13 +7,15 @@ * 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited. */ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { FilterQuery } from 'mongoose'; import EventWrapper from '@/channel/lib/EventWrapper'; import { LoggerService } from '@/logger/logger.service'; import { BaseService } from '@/utils/generics/base-service'; +import { ContextVarService } from './context-var.service'; +import { SubscriberService } from './subscriber.service'; import { VIEW_MORE_PAYLOAD } from '../helpers/constants'; import { ConversationRepository } from '../repositories/conversation.repository'; import { Block, BlockFull } from '../schemas/block.schema'; @@ -34,6 +36,8 @@ export class ConversationService extends BaseService< constructor( readonly repository: ConversationRepository, private readonly logger: LoggerService, + private readonly contextVarService: ContextVarService, + private readonly subscriberService: SubscriberService, ) { super(repository); } @@ -79,6 +83,7 @@ export class ConversationService extends BaseService< captureVars: boolean = false, ) { const msgType = event.getMessageType(); + const profile = event.getSender(); // Capture channel specific context data convo.context.channel = event.getHandler().getChannel(); convo.context.text = event.getText(); @@ -86,6 +91,9 @@ export class ConversationService extends BaseService< convo.context.nlp = event.getNLP(); convo.context.vars = convo.context.vars || {}; + const contextVars = + await this.contextVarService.getContextVarsByBlock(next); + // Capture user entry in context vars if (captureVars && next.capture_vars && next.capture_vars.length > 0) { next.capture_vars.forEach((capture) => { @@ -121,12 +129,18 @@ export class ConversationService extends BaseService< contextValue = typeof contextValue === 'string' ? contextValue.trim() : contextValue; - convo.context.vars[capture.context_var] = contextValue; + if (contextVars[capture.context_var]?.permanent) { + Logger.debug( + `Adding context var to subscriber: ${capture.context_var} = ${contextValue}`, + ); + profile.context.vars[capture.context_var] = contextValue; + } else { + convo.context.vars[capture.context_var] = contextValue; + } }); } // Store user infos - const profile = event.getSender(); if (profile) { // @ts-expect-error : id needs to remain readonly convo.context.user.id = profile.id; @@ -182,6 +196,13 @@ export class ConversationService extends BaseService< 'Conversation Model : No conversation has been updated', ); } + + //TODO: add check if nothing changed don't update + + await this.subscriberService.updateOne(convo.sender, { + context: profile.context, + }); + return updatedConversation; } catch (err) { this.logger.error('Conversation Model : Unable to store context', err); diff --git a/api/src/utils/test/fixtures/contextvar.ts b/api/src/utils/test/fixtures/contextvar.ts index a3ffc896..4ccea4bb 100644 --- a/api/src/utils/test/fixtures/contextvar.ts +++ b/api/src/utils/test/fixtures/contextvar.ts @@ -18,10 +18,12 @@ const contextVars: ContextVarCreateDto[] = [ { label: 'test context var 1', name: 'test1', + permanent: false, }, { label: 'test context var 2', name: 'test2', + permanent: false, }, ]; diff --git a/api/src/utils/test/mocks/conversation.ts b/api/src/utils/test/mocks/conversation.ts index c08fbbf4..7063f7d8 100644 --- a/api/src/utils/test/mocks/conversation.ts +++ b/api/src/utils/test/mocks/conversation.ts @@ -10,6 +10,7 @@ import { Block, BlockStub } from '@/chat/schemas/block.schema'; import { ConversationFull } from '@/chat/schemas/conversation.schema'; import { Context } from '@/chat/schemas/types/context'; +import { SubscriberContext } from '@/chat/schemas/types/subscriberContext'; import { quickRepliesBlock, textBlock } from './block'; import { modelInstance } from './misc'; @@ -30,6 +31,10 @@ export const contextBlankInstance: Context = { attempt: 1, }; +export const subscriberContextBlankInstance: SubscriberContext = { + vars: {}, +}; + export const contextEmailVarInstance: Context = { ...contextBlankInstance, vars: { diff --git a/frontend/src/components/context-vars/ContextVarDialog.tsx b/frontend/src/components/context-vars/ContextVarDialog.tsx index 5a396187..3b973684 100644 --- a/frontend/src/components/context-vars/ContextVarDialog.tsx +++ b/frontend/src/components/context-vars/ContextVarDialog.tsx @@ -7,9 +7,16 @@ * 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited. */ -import { Dialog, DialogActions, DialogContent } from "@mui/material"; +import { + Dialog, + DialogActions, + DialogContent, + FormControlLabel, + FormHelperText, + Switch, +} from "@mui/material"; import { FC, useEffect } from "react"; -import { useForm } from "react-hook-form"; +import { Controller, useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; import DialogButtons from "@/app-components/buttons/DialogButtons"; @@ -58,6 +65,7 @@ export const ContextVarDialog: FC = ({ setValue, handleSubmit, formState: { errors }, + control, } = useForm({ defaultValues: { name: data?.name || "", label: data?.label || "" }, }); @@ -129,6 +137,19 @@ export const ContextVarDialog: FC = ({ InputLabelProps={{ shrink: true }} /> + + ( + } + label={t("label.permanent")} + /> + )} + /> + {t("help.permanent")} + diff --git a/frontend/src/components/context-vars/index.tsx b/frontend/src/components/context-vars/index.tsx index 68a095f1..c6458f07 100644 --- a/frontend/src/components/context-vars/index.tsx +++ b/frontend/src/components/context-vars/index.tsx @@ -9,7 +9,7 @@ import { faAsterisk } from "@fortawesome/free-solid-svg-icons"; import AddIcon from "@mui/icons-material/Add"; -import { Button, Grid, Paper } from "@mui/material"; +import { Button, Grid, Paper, Switch } from "@mui/material"; import { GridColDef } from "@mui/x-data-grid"; import React from "react"; import { useTranslation } from "react-i18next"; @@ -24,6 +24,7 @@ import { renderHeader } from "@/app-components/tables/columns/renderHeader"; import { DataGrid } from "@/app-components/tables/DataGrid"; import { useDelete } from "@/hooks/crud/useDelete"; import { useFind } from "@/hooks/crud/useFind"; +import { useUpdate } from "@/hooks/crud/useUpdate"; import { getDisplayDialogs, useDialog } from "@/hooks/useDialog"; import { useHasPermission } from "@/hooks/useHasPermission"; import { useSearch } from "@/hooks/useSearch"; @@ -52,6 +53,14 @@ export const ContextVars = () => { params: searchPayload, }, ); + const { mutateAsync: updateContextVar } = useUpdate(EntityType.CONTEXT_VAR, { + onError: () => { + toast.error(t("message.internal_server_error")); + }, + onSuccess() { + toast.success(t("message.success_save")); + }, + }); const { mutateAsync: deleteContextVar } = useDelete(EntityType.CONTEXT_VAR, { onError: () => { toast.error(t("message.internal_server_error")); @@ -87,6 +96,27 @@ export const ContextVars = () => { renderHeader, headerAlign: "left", }, + { + maxWidth: 120, + field: "permanent", + headerName: t("label.permanent"), + disableColumnMenu: true, + renderHeader, + headerAlign: "left", + renderCell: (params) => ( + { + updateContextVar({ + id: params.row.id, + params: { permanent: !params.value }, + }); + }} + /> + ), + }, { maxWidth: 140, field: "createdAt", diff --git a/frontend/src/i18n/en/translation.json b/frontend/src/i18n/en/translation.json index fc34769d..69fc736f 100644 --- a/frontend/src/i18n/en/translation.json +++ b/frontend/src/i18n/en/translation.json @@ -292,6 +292,7 @@ "assign_labels": "Assign labels", "replacement_tokens": "Replacement Tokens", "built_in": "Built-in", + "permanent": "Permanent", "assign_to": "Takeover By", "assigned_to": "Assigned To", "user_first_name": "First Name", @@ -698,7 +699,8 @@ "supported_message_type_others": "Only generic template messages are supported.", "notification_type_regular": "Sound/Vibration", "notification_type_silent_push": "On-screen notification only", - "notification_type_no_push": "No notification" + "notification_type_no_push": "No notification", + "permanent": "When enabled, the variable value will be stored in the subscriber's profile and retained for future conversations." }, "charts": { "messages": "Messages", diff --git a/frontend/src/i18n/fr/translation.json b/frontend/src/i18n/fr/translation.json index d61fc437..a23bf751 100644 --- a/frontend/src/i18n/fr/translation.json +++ b/frontend/src/i18n/fr/translation.json @@ -293,6 +293,7 @@ "assign_labels": "Affecter des étiquettes", "replacement_tokens": "Jetons de remplacement", "built_in": "Intégré", + "permanent": "Permanent", "assign_to": "Assigner à", "assigned_to": "Assigné(e) à", "user_first_name": "Prénom", @@ -696,7 +697,8 @@ "supported_message_type_others": "Seuls les messages de modèle générique sont pris en charge.", "notification_type_regular": "Son/Vibration", "notification_type_silent_push": "Notification à l'écran uniquement", - "notification_type_no_push": "Aucune notification" + "notification_type_no_push": "Aucune notification", + "permanent": "Lorsqu'elle est activée, cette variable sera stockée dans le profil de l'abonné(e) et conservée pour les futures conversations." }, "charts": { "messages": "Messages", diff --git a/frontend/src/types/context-var.types.ts b/frontend/src/types/context-var.types.ts index fa388266..0cf795ee 100644 --- a/frontend/src/types/context-var.types.ts +++ b/frontend/src/types/context-var.types.ts @@ -14,14 +14,12 @@ import { IBaseSchema, IFormat, OmitPopulate } from "./base.types"; export interface IContextVarAttributes { name: string; label: string; + permanent: boolean; } export interface IContextVarStub extends IBaseSchema, - OmitPopulate { - name: string; - label: string; -} + OmitPopulate {} export interface IContextVar extends IContextVarStub, IFormat {}