Merge pull request #1016 from Hexastack/fix/permanent-ctx-var-update
Some checks failed
Build and Push Docker API Image / build-and-push (push) Has been cancelled
Build and Push Docker Base Image / build-and-push (push) Has been cancelled
Build and Push Docker UI Image / build-and-push (push) Has been cancelled

Fix/permanent ctx var update
This commit is contained in:
Med Marrouchi
2025-05-16 19:23:16 +01:00
committed by GitHub
8 changed files with 409 additions and 45 deletions

View File

@@ -128,6 +128,7 @@ describe('SubscriberController', () => {
),
labels: labelIDs,
assignedTo: allUsers.find(({ id }) => subscriber.assignedTo === id)?.id,
context: undefined,
});
});
@@ -148,6 +149,7 @@ describe('SubscriberController', () => {
subscriber.labels.includes(label.id),
),
assignedTo: allUsers.find(({ id }) => subscriber.assignedTo === id),
context: undefined,
});
});
});

View File

@@ -119,6 +119,7 @@ describe('SubscriberRepository', () => {
subscriber.labels.includes(label.id),
),
assignedTo: allUsers.find(({ id }) => subscriber.assignedTo === id),
context: undefined,
};
expect(subscriberModel.findById).toHaveBeenCalledWith(

View File

@@ -0,0 +1,354 @@
/*
* Copyright © 2025 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/
import { MongooseModule } from '@nestjs/mongoose';
import { AttachmentRepository } from '@/attachment/repositories/attachment.repository';
import { AttachmentModel } from '@/attachment/schemas/attachment.schema';
import { AttachmentService } from '@/attachment/services/attachment.service';
import EventWrapper from '@/channel/lib/EventWrapper';
import { installContextVarFixtures } from '@/utils/test/fixtures/contextvar';
import { installConversationTypeFixtures } from '@/utils/test/fixtures/conversation';
import {
closeInMongodConnection,
rootMongooseTestModule,
} from '@/utils/test/test';
import { buildTestingMocks } from '@/utils/test/utils';
import { VIEW_MORE_PAYLOAD } from '../helpers/constants';
import { ContextVarRepository } from '../repositories/context-var.repository';
import { ConversationRepository } from '../repositories/conversation.repository';
import { SubscriberRepository } from '../repositories/subscriber.repository';
import { Block } from '../schemas/block.schema';
import { ContextVarModel } from '../schemas/context-var.schema';
import { ConversationModel } from '../schemas/conversation.schema';
import { SubscriberModel } from '../schemas/subscriber.schema';
import { OutgoingMessageFormat } from '../schemas/types/message';
import { ContextVarService } from './context-var.service';
import { ConversationService } from './conversation.service';
import { SubscriberService } from './subscriber.service';
describe('ConversationService', () => {
let conversationService: ConversationService;
let subscriberService: SubscriberService;
// let labelService: LabelService;
// let subscriberRepository: SubscriberRepository;
// let allSubscribers: Subscriber[];
// let allLabels: Label[];
// let labelsWithUsers: LabelFull[];
beforeAll(async () => {
const { getMocks } = await buildTestingMocks({
imports: [
rootMongooseTestModule(async () => {
await installContextVarFixtures();
await installConversationTypeFixtures();
}),
MongooseModule.forFeature([
// LabelModel,
SubscriberModel,
ConversationModel,
AttachmentModel,
ContextVarModel,
]),
],
providers: [
// LabelService,
// LabelRepository,
AttachmentService,
SubscriberService,
ConversationService,
ContextVarService,
AttachmentRepository,
SubscriberRepository,
ConversationRepository,
ContextVarRepository,
],
});
[conversationService, subscriberService] = await getMocks([
ConversationService,
SubscriberService,
]);
// allSubscribers = await subscriberRepository.findAll();
// allLabels = await labelRepository.findAll();
// labelsWithUsers = allLabels.map((label) => ({
// ...label,
// users: allSubscribers,
// }));
});
afterEach(jest.clearAllMocks);
afterAll(closeInMongodConnection);
describe('ConversationService.storeContextData', () => {
it('should enrich the conversation context and persist conversation + subscriber (permanent)', async () => {
const subscriber = (await subscriberService.findOne({
foreign_id: 'foreign-id-messenger',
}))!;
const conversation = (await conversationService.findOne({
sender: subscriber.id,
}))!;
const next = {
id: 'block-1',
capture_vars: [{ entity: -1, context_var: 'phone' }],
} as Block;
const mockPhone = '+1 514 678 9873';
const event = {
getMessageType: jest.fn().mockReturnValue('message'),
getText: jest.fn().mockReturnValue(mockPhone),
getPayload: jest.fn().mockReturnValue(undefined),
getNLP: jest.fn().mockReturnValue(undefined),
getMessage: jest.fn().mockReturnValue({
text: mockPhone,
}),
getHandler: jest.fn().mockReturnValue({
getName: jest.fn().mockReturnValue('messenger-channel'),
}),
getSender: jest.fn().mockReturnValue({
id: subscriber.id,
first_name: subscriber.first_name,
last_name: subscriber.last_name,
language: subscriber.language,
context: {
vars: {
email: 'john.doe@mail.com',
},
},
}),
setSender: jest.fn(),
} as unknown as EventWrapper<any, any>;
const result = await conversationService.storeContextData(
conversation!,
next,
event,
true,
);
// ---- Assertions ------------------------------------------------------
expect(result.context.channel).toBe('messenger-channel');
expect(result.context.text).toBe(mockPhone);
expect(result.context.vars.phone).toBe(mockPhone);
expect(result.context.user).toEqual({
id: subscriber.id,
first_name: subscriber.first_name,
last_name: subscriber.last_name,
language: subscriber.language,
});
const updatedSubscriber = (await subscriberService.findOne({
foreign_id: 'foreign-id-messenger',
}))!;
expect(updatedSubscriber.context.vars?.phone).toBe(mockPhone);
// expect(event.setSender).toHaveBeenCalledWith(updatedSubscriber);
});
it('should capture an NLP entity value into context vars (non-permanent)', async () => {
const subscriber = (await subscriberService.findOne({
foreign_id: 'foreign-id-messenger',
}))!;
const conversation = (await conversationService.findOne({
sender: subscriber.id,
}))!;
const next = {
id: 'block-1',
capture_vars: [{ entity: 'country_code', context_var: 'country' }],
} as Block;
const mockMessage = 'Are you from the US?';
const event = {
getMessageType: jest.fn().mockReturnValue('message'),
getText: jest.fn().mockReturnValue(mockMessage),
getPayload: jest.fn().mockReturnValue(undefined),
getNLP: jest.fn().mockReturnValue({
entities: [
{
entity: 'country_code',
value: 'US',
},
],
}),
getMessage: jest.fn().mockReturnValue({
text: mockMessage,
}),
getHandler: jest.fn().mockReturnValue({
getName: jest.fn().mockReturnValue('messenger-channel'),
}),
getSender: jest.fn().mockReturnValue({
id: subscriber.id,
first_name: subscriber.first_name,
last_name: subscriber.last_name,
language: subscriber.language,
context: {
vars: {
email: 'john.doe@mail.com',
},
},
}),
setSender: jest.fn(),
} as unknown as EventWrapper<any, any>;
const result = await conversationService.storeContextData(
conversation,
next,
event,
true,
);
expect(result.context.vars.country).toBe('US');
const updatedSubscriber = (await subscriberService.findOne({
foreign_id: 'foreign-id-messenger',
}))!;
expect(updatedSubscriber.context.vars?.country).toBe(undefined);
});
it('should capture user coordinates when message type is "location"', async () => {
const subscriber = (await subscriberService.findOne({
foreign_id: 'foreign-id-messenger',
}))!;
const conversation = (await conversationService.findOne({
sender: subscriber.id,
}))!;
const next = {
id: 'block-1',
capture_vars: [{ entity: 'country_code', context_var: 'country' }],
} as Block;
const event = {
getMessageType: jest.fn().mockReturnValue('location'),
getText: jest.fn().mockReturnValue(''),
getPayload: jest.fn().mockReturnValue(undefined),
getNLP: jest.fn(),
getMessage: jest.fn().mockReturnValue({
coordinates: { lat: 36.8065, lon: 10.1815 },
}),
getHandler: jest.fn().mockReturnValue({
getName: jest.fn().mockReturnValue('messenger-channel'),
}),
getSender: jest.fn().mockReturnValue({
id: subscriber.id,
first_name: subscriber.first_name,
last_name: subscriber.last_name,
language: subscriber.language,
context: {
vars: {
email: 'john.doe@mail.com',
},
},
}),
setSender: jest.fn(),
} as unknown as EventWrapper<any, any>;
const result = await conversationService.storeContextData(
conversation,
next,
event,
);
expect(result.context.user_location).toEqual({
lat: 36.8065,
lon: 10.1815,
});
});
it('should increment skip when VIEW_MORE payload is received for list/carousel blocks', async () => {
const subscriber = (await subscriberService.findOne({
foreign_id: 'foreign-id-messenger',
}))!;
const conversation = (await conversationService.findOne({
sender: subscriber.id,
}))!;
const next = {
id: 'block-1',
capture_vars: [],
options: {
content: {
display: OutgoingMessageFormat.list,
limit: 10,
},
},
} as unknown as Block;
const event = {
getMessageType: jest.fn().mockReturnValue('message'),
getText: jest.fn().mockReturnValue('I would like to see the products'),
getPayload: jest.fn().mockReturnValue(undefined),
getNLP: jest.fn(),
getMessage: jest.fn().mockReturnValue({
text: 'I would like to see the products',
}),
getHandler: jest.fn().mockReturnValue({
getName: jest.fn().mockReturnValue('messenger-channel'),
}),
getSender: jest.fn().mockReturnValue({
id: subscriber.id,
first_name: subscriber.first_name,
last_name: subscriber.last_name,
language: subscriber.language,
context: {
vars: {
email: 'john.doe@mail.com',
},
},
}),
setSender: jest.fn(),
} as unknown as EventWrapper<any, any>;
const result1 = await conversationService.storeContextData(
conversation,
next,
event,
);
expect(result1.context.skip['block-1']).toBe(0);
const event2 = {
getMessageType: jest.fn().mockReturnValue('postback'),
getText: jest.fn().mockReturnValue('View more'),
getPayload: jest.fn().mockReturnValue(VIEW_MORE_PAYLOAD),
getNLP: jest.fn(),
getMessage: jest.fn().mockReturnValue({
coordinates: { lat: 36.8065, lon: 10.1815 },
}),
getHandler: jest.fn().mockReturnValue({
getName: jest.fn().mockReturnValue('messenger-channel'),
}),
getSender: jest.fn().mockReturnValue({
id: subscriber.id,
first_name: subscriber.first_name,
last_name: subscriber.last_name,
language: subscriber.language,
context: {
vars: {
email: 'john.doe@mail.com',
},
},
}),
setSender: jest.fn(),
} as unknown as EventWrapper<any, any>;
// Second call to ensure the offset keeps growing
const result2 = await conversationService.storeContextData(
conversation,
next,
event2,
);
expect(result2.context.skip['block-1']).toBe(10);
});
});
});

View File

@@ -6,7 +6,7 @@
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/
import { Injectable, Logger } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import EventWrapper from '@/channel/lib/EventWrapper';
import { BaseService } from '@/utils/generics/base-service';
@@ -56,7 +56,7 @@ export class ConversationService extends BaseService<
* @param convo - The Current Conversation
* @param next - The next block to be triggered
* @param event - The event received
* @param captureVars - If we should capture vars or not
* @param shouldCaptureVars - If we should capture vars or not
*
* @returns The updated conversation
*/
@@ -64,7 +64,7 @@ export class ConversationService extends BaseService<
convo: Conversation | ConversationFull,
next: Block | BlockFull,
event: EventWrapper<any, any>,
captureVars: boolean = false,
shouldCaptureVars: boolean = false,
) {
const msgType = event.getMessageType();
const profile = event.getSender();
@@ -80,7 +80,11 @@ export class ConversationService extends BaseService<
await this.contextVarService.getContextVarsByBlock(next);
// Capture user entry in context vars
if (captureVars && next.capture_vars && next.capture_vars.length > 0) {
if (
shouldCaptureVars &&
next.capture_vars &&
next.capture_vars.length > 0
) {
next.capture_vars.forEach((capture) => {
let contextValue: string | Payload | undefined;
@@ -95,9 +99,6 @@ export class ConversationService extends BaseService<
if (capture.entity && nlpIndex !== -1) {
// Get the most confident value
contextValue = nlp.entities[nlpIndex].value;
// .reduce((prev, current) => {
// return (prev.confidence > current.confidence) ? prev : current;
// }, {value: '', confidence: 0}).value;
}
}
@@ -115,15 +116,6 @@ export class ConversationService extends BaseService<
typeof contextValue === 'string' ? contextValue.trim() : contextValue;
if (contextValue) {
if (
profile.context?.vars &&
contextVars[capture.context_var]?.permanent
) {
Logger.debug(
`Adding context var to subscriber: ${capture.context_var} = ${contextValue}`,
);
profile.context.vars[capture.context_var] = contextValue;
}
convo.context.vars[capture.context_var] = contextValue;
}
});
@@ -164,9 +156,7 @@ export class ConversationService extends BaseService<
convo.context.skip[next.id] = 0;
}
}
// Execute additional logic provided by plugins on the context
// @todo : uncomment once plugin module is tested
// sails.plugins.applyEffect('onStoreContextData', next.options.effects || [], [convo, next, event, captureVars]);
// Store new context data
try {
const updatedConversation = await this.updateOne(convo.id, {
@@ -177,15 +167,29 @@ export class ConversationService extends BaseService<
const criteria =
typeof convo.sender === 'object' ? convo.sender.id : convo.sender;
await this.subscriberService.updateOne(
criteria,
{
context: profile.context,
},
{
shouldFlatten: true,
},
);
// Store permanent context vars at the subscriber level
const permanentContextVars = Object.entries(contextVars)
.filter(([, { permanent }]) => permanent)
.reduce((acc, [cur]) => {
if (cur in convo.context.vars) {
acc[cur] = convo.context.vars[cur];
}
return acc;
}, {});
if (Object.keys(permanentContextVars).length) {
const updatedSubscriber = await this.subscriberService.updateOne(
criteria,
{
context: { vars: permanentContextVars },
},
{
shouldFlatten: true,
},
);
event.setSender(updatedSubscriber);
}
return updatedConversation;
} catch (err) {

View File

@@ -196,8 +196,10 @@ export const installBlockFixtures = async () => {
category: defaultCategory.id,
})),
);
return await Block.updateOne(
await Block.updateOne(
{ name: 'hasNextBlocks' },
{ $set: { nextBlocks: blocks[1].id } },
);
return blocks;
};

View File

@@ -21,6 +21,16 @@ export const contentVarDefaultValues: TContentVarFixtures['defaultValues'] = {
};
const contextVars: TContentVarFixtures['values'][] = [
{
label: 'Phone',
name: 'phone',
permanent: true,
},
{
label: 'Country',
name: 'country',
permanent: false,
},
{
label: 'test context var 1',
name: 'test1',

View File

@@ -1,5 +1,5 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
* Copyright © 2025 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
@@ -10,6 +10,7 @@ import mongoose from 'mongoose';
import { ConversationCreateDto } from '@/chat/dto/conversation.dto';
import { ConversationModel } from '@/chat/schemas/conversation.schema';
import { Subscriber } from '@/chat/schemas/subscriber.schema';
import { getFixturesWithDefaultValues } from '../defaultValues';
import { TFixturesDefaultValues } from '../types';
@@ -45,23 +46,10 @@ const conversations: ConversationCreateDto[] = [
},
user: {
id: '1',
createdAt: new Date(),
updatedAt: new Date(),
first_name: 'Jhon',
last_name: 'Doe',
language: 'fr',
locale: 'en_EN',
timezone: 0,
gender: 'male',
country: 'FR',
foreign_id: '',
labels: [],
assignedTo: null,
channel: { name: 'messenger-channel' },
avatar: null,
context: {},
assignedAt: new Date(),
},
} as Subscriber,
skip: {},
attempt: 0,
},
@@ -131,7 +119,7 @@ export const conversationFixtures =
});
export const installConversationTypeFixtures = async () => {
const subscribers = await installSubscriberFixtures();
const { subscribers } = await installSubscriberFixtures();
const blocks = await installBlockFixtures();
const Conversation = mongoose.model(

View File

@@ -26,6 +26,9 @@ export const subscriberDefaultValues: TSubscriberFixtures['defaultValues'] = {
lastvisit: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
retainedFrom: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
avatar: null,
context: {
vars: {},
},
};
const subscribers: TSubscriberFixtures['values'][] = [