mirror of
https://github.com/hexastack/hexabot
synced 2025-06-26 18:27:28 +00:00
feat: initial commit
This commit is contained in:
543
api/src/chat/services/block.service.spec.ts
Normal file
543
api/src/chat/services/block.service.spec.ts
Normal file
@@ -0,0 +1,543 @@
|
||||
/*
|
||||
* Copyright © 2024 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).
|
||||
* 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 { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { MongooseModule } from '@nestjs/mongoose';
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import { AttachmentRepository } from '@/attachment/repositories/attachment.repository';
|
||||
import { AttachmentModel } from '@/attachment/schemas/attachment.schema';
|
||||
import { AttachmentService } from '@/attachment/services/attachment.service';
|
||||
import {
|
||||
subscriberWithLabels,
|
||||
subscriberWithoutLabels,
|
||||
} from '@/channel/lib/__test__/subscriber.mock';
|
||||
import { ContentTypeRepository } from '@/cms/repositories/content-type.repository';
|
||||
import { ContentRepository } from '@/cms/repositories/content.repository';
|
||||
import { ContentTypeModel } from '@/cms/schemas/content-type.schema';
|
||||
import { Content, ContentModel } from '@/cms/schemas/content.schema';
|
||||
import { ContentTypeService } from '@/cms/services/content-type.service';
|
||||
import { ContentService } from '@/cms/services/content.service';
|
||||
import { ExtendedI18nService } from '@/extended-i18n.service';
|
||||
import OfflineHandler from '@/extensions/channels/offline/index.channel';
|
||||
import { OFFLINE_CHANNEL_NAME } from '@/extensions/channels/offline/settings';
|
||||
import { Offline } from '@/extensions/channels/offline/types';
|
||||
import OfflineEventWrapper from '@/extensions/channels/offline/wrapper';
|
||||
import { LoggerService } from '@/logger/logger.service';
|
||||
import { PluginService } from '@/plugins/plugins.service';
|
||||
import { Settings } from '@/setting/schemas/types';
|
||||
import { SettingService } from '@/setting/services/setting.service';
|
||||
import {
|
||||
blockFixtures,
|
||||
installBlockFixtures,
|
||||
} from '@/utils/test/fixtures/block';
|
||||
import { installContentFixtures } from '@/utils/test/fixtures/content';
|
||||
import {
|
||||
blockEmpty,
|
||||
blockGetStarted,
|
||||
blockProductListMock,
|
||||
blocks,
|
||||
} from '@/utils/test/mocks/block';
|
||||
import {
|
||||
contextBlankInstance,
|
||||
contextEmailVarInstance,
|
||||
contextGetStartedInstance,
|
||||
} from '@/utils/test/mocks/conversation';
|
||||
import { nlpEntitiesGreeting } from '@/utils/test/mocks/nlp';
|
||||
import {
|
||||
closeInMongodConnection,
|
||||
rootMongooseTestModule,
|
||||
} from '@/utils/test/test';
|
||||
|
||||
import { CategoryRepository } from './../repositories/category.repository';
|
||||
import { BlockService } from './block.service';
|
||||
import { CategoryService } from './category.service';
|
||||
import { BlockRepository } from '../repositories/block.repository';
|
||||
import { Block, BlockModel } from '../schemas/block.schema';
|
||||
import { Category, CategoryModel } from '../schemas/category.schema';
|
||||
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';
|
||||
|
||||
describe('BlockService', () => {
|
||||
let blockRepository: BlockRepository;
|
||||
let categoryRepository: CategoryRepository;
|
||||
let category: Category;
|
||||
let block: Block;
|
||||
let blockService: BlockService;
|
||||
let hasPreviousBlocks: Block;
|
||||
let contentService: ContentService;
|
||||
let contentTypeService: ContentTypeService;
|
||||
let settingService: SettingService;
|
||||
let settings: Settings;
|
||||
|
||||
beforeAll(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
imports: [
|
||||
rootMongooseTestModule(async () => {
|
||||
await installContentFixtures();
|
||||
await installBlockFixtures();
|
||||
}),
|
||||
MongooseModule.forFeature([
|
||||
BlockModel,
|
||||
CategoryModel,
|
||||
ContentTypeModel,
|
||||
ContentModel,
|
||||
AttachmentModel,
|
||||
LabelModel,
|
||||
]),
|
||||
],
|
||||
providers: [
|
||||
BlockRepository,
|
||||
CategoryRepository,
|
||||
ContentTypeRepository,
|
||||
ContentRepository,
|
||||
AttachmentRepository,
|
||||
BlockService,
|
||||
CategoryService,
|
||||
ContentTypeService,
|
||||
ContentService,
|
||||
AttachmentService,
|
||||
{
|
||||
provide: PluginService,
|
||||
useValue: {},
|
||||
},
|
||||
LoggerService,
|
||||
{
|
||||
provide: ExtendedI18nService,
|
||||
useValue: {
|
||||
t: jest.fn().mockImplementation((t) => {
|
||||
return t === 'Welcome' ? 'Bienvenue' : t;
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: SettingService,
|
||||
useValue: {
|
||||
getConfig: jest.fn(() => ({
|
||||
chatbot: { lang: { default: 'fr' } },
|
||||
})),
|
||||
getSettings: jest.fn(() => ({
|
||||
contact: { company_name: 'Your company name' },
|
||||
})),
|
||||
},
|
||||
},
|
||||
EventEmitter2,
|
||||
],
|
||||
}).compile();
|
||||
blockService = module.get<BlockService>(BlockService);
|
||||
contentService = module.get<ContentService>(ContentService);
|
||||
settingService = module.get<SettingService>(SettingService);
|
||||
contentTypeService = module.get<ContentTypeService>(ContentTypeService);
|
||||
categoryRepository = module.get<CategoryRepository>(CategoryRepository);
|
||||
blockRepository = module.get<BlockRepository>(BlockRepository);
|
||||
category = await categoryRepository.findOne({ label: 'default' });
|
||||
hasPreviousBlocks = await blockRepository.findOne({
|
||||
name: 'hasPreviousBlocks',
|
||||
});
|
||||
block = await blockRepository.findOne({ name: 'hasNextBlocks' });
|
||||
settings = await settingService.getSettings();
|
||||
});
|
||||
|
||||
afterEach(jest.clearAllMocks);
|
||||
afterAll(closeInMongodConnection);
|
||||
|
||||
describe('findOneAndPopulate', () => {
|
||||
it('should find one block by id, and populate its trigger_labels, assign_labels,attachedBlock,category,nextBlocks', async () => {
|
||||
jest.spyOn(blockRepository, 'findOneAndPopulate');
|
||||
const result = await blockService.findOneAndPopulate(block.id);
|
||||
|
||||
expect(blockRepository.findOneAndPopulate).toHaveBeenCalledWith(block.id);
|
||||
expect(result).toEqualPayload({
|
||||
...blockFixtures.find(({ name }) => name === 'hasNextBlocks'),
|
||||
category,
|
||||
nextBlocks: [hasPreviousBlocks],
|
||||
previousBlocks: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAndPopulate', () => {
|
||||
it('should find blocks and populate them', async () => {
|
||||
jest.spyOn(blockRepository, 'findAndPopulate');
|
||||
const result = await blockService.findAndPopulate({});
|
||||
const blocksWithCategory = blockFixtures.map((blockFixture) => ({
|
||||
...blockFixture,
|
||||
category,
|
||||
previousBlocks:
|
||||
blockFixture.name === 'hasPreviousBlocks' ? [block] : [],
|
||||
nextBlocks:
|
||||
blockFixture.name === 'hasNextBlocks' ? [hasPreviousBlocks] : [],
|
||||
}));
|
||||
|
||||
expect(blockRepository.findAndPopulate).toHaveBeenCalledWith({});
|
||||
expect(result).toEqualPayload(blocksWithCategory);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRandom', () => {
|
||||
it('should get a random message', () => {
|
||||
const messages = [
|
||||
'Hello, this is Nour',
|
||||
'Oh ! How are you ?',
|
||||
"Hmmm that's cool !",
|
||||
'Corona virus',
|
||||
'God bless you',
|
||||
];
|
||||
const result = blockService.getRandom(messages);
|
||||
expect(messages).toContain(result);
|
||||
});
|
||||
|
||||
it('should return undefined when trying to get a random message from an empty array', () => {
|
||||
const result = blockService.getRandom([]);
|
||||
expect(result).toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('match', () => {
|
||||
const handlerMock = {
|
||||
getChannel: jest.fn(() => OFFLINE_CHANNEL_NAME),
|
||||
} as any as OfflineHandler;
|
||||
const offlineEventGreeting = new OfflineEventWrapper(
|
||||
handlerMock,
|
||||
{
|
||||
type: Offline.IncomingMessageType.text,
|
||||
data: {
|
||||
text: 'Hello',
|
||||
},
|
||||
},
|
||||
{},
|
||||
);
|
||||
const offlineEventGetStarted = new OfflineEventWrapper(
|
||||
handlerMock,
|
||||
{
|
||||
type: Offline.IncomingMessageType.postback,
|
||||
data: {
|
||||
text: 'Get Started',
|
||||
payload: 'GET_STARTED',
|
||||
},
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
it('should return undefined when no blocks are provided', async () => {
|
||||
const result = await blockService.match([], offlineEventGreeting);
|
||||
expect(result).toBe(undefined);
|
||||
});
|
||||
|
||||
it('should return undefined for empty blocks', async () => {
|
||||
const result = await blockService.match(
|
||||
[blockEmpty],
|
||||
offlineEventGreeting,
|
||||
);
|
||||
expect(result).toEqual(undefined);
|
||||
});
|
||||
|
||||
it('should return undefined for no matching labels', async () => {
|
||||
offlineEventGreeting.setSender(subscriberWithoutLabels);
|
||||
const result = await blockService.match(blocks, offlineEventGreeting);
|
||||
expect(result).toEqual(undefined);
|
||||
});
|
||||
|
||||
it('should match block text and labels', async () => {
|
||||
offlineEventGreeting.setSender(subscriberWithLabels);
|
||||
const result = await blockService.match(blocks, offlineEventGreeting);
|
||||
expect(result).toEqual(blockGetStarted);
|
||||
});
|
||||
|
||||
it('should match block with payload', async () => {
|
||||
offlineEventGetStarted.setSender(subscriberWithLabels);
|
||||
const result = await blockService.match(blocks, offlineEventGetStarted);
|
||||
expect(result).toEqual(blockGetStarted);
|
||||
});
|
||||
|
||||
it('should match block with nlp', async () => {
|
||||
offlineEventGreeting.setSender(subscriberWithLabels);
|
||||
offlineEventGreeting.setNLP(nlpEntitiesGreeting);
|
||||
const result = await blockService.match(blocks, offlineEventGreeting);
|
||||
expect(result).toEqual(blockGetStarted);
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchNLP', () => {
|
||||
it('should return undefined for match nlp against a block with no patterns', () => {
|
||||
const result = blockService.matchNLP(nlpEntitiesGreeting, blockEmpty);
|
||||
expect(result).toEqual(undefined);
|
||||
});
|
||||
|
||||
it('should return undefined for match nlp when no nlp entities are provided', () => {
|
||||
const result = blockService.matchNLP({ entities: [] }, blockGetStarted);
|
||||
expect(result).toEqual(undefined);
|
||||
});
|
||||
|
||||
it('should return match nlp patterns', () => {
|
||||
const result = blockService.matchNLP(
|
||||
nlpEntitiesGreeting,
|
||||
blockGetStarted,
|
||||
);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
entity: 'intent',
|
||||
match: 'value',
|
||||
value: 'greeting',
|
||||
},
|
||||
{
|
||||
entity: 'firstname',
|
||||
match: 'entity',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return undefined when it does not match nlp patterns', () => {
|
||||
const result = blockService.matchNLP(nlpEntitiesGreeting, {
|
||||
...blockGetStarted,
|
||||
patterns: [[{ entity: 'lastname', match: 'value', value: 'Belakhel' }]],
|
||||
});
|
||||
expect(result).toEqual(undefined);
|
||||
});
|
||||
|
||||
it('should return undefined when unknown nlp patterns', () => {
|
||||
const result = blockService.matchNLP(nlpEntitiesGreeting, {
|
||||
...blockGetStarted,
|
||||
patterns: [[{ entity: 'product', match: 'value', value: 'pizza' }]],
|
||||
});
|
||||
expect(result).toEqual(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchPayload', () => {
|
||||
it('should return undefined for empty payload', () => {
|
||||
const result = blockService.matchPayload('', blockGetStarted);
|
||||
expect(result).toEqual(undefined);
|
||||
});
|
||||
|
||||
it('should return undefined for empty block', () => {
|
||||
const result = blockService.matchPayload('test', blockEmpty);
|
||||
expect(result).toEqual(undefined);
|
||||
});
|
||||
|
||||
it('should match payload and return object for label string', () => {
|
||||
const location = {
|
||||
label: 'Tounes',
|
||||
value: 'Tounes',
|
||||
type: 'location',
|
||||
};
|
||||
const result = blockService.matchPayload('Tounes', blockGetStarted);
|
||||
expect(result).toEqual(location);
|
||||
});
|
||||
|
||||
it('should match payload and return object for value string', () => {
|
||||
const result = blockService.matchPayload('GET_STARTED', blockGetStarted);
|
||||
expect(result).toEqual({
|
||||
label: 'Get Started',
|
||||
value: 'GET_STARTED',
|
||||
});
|
||||
});
|
||||
|
||||
it("should match payload when it's an attachment location", () => {
|
||||
const result = blockService.matchPayload(
|
||||
{
|
||||
type: PayloadType.location,
|
||||
coordinates: {
|
||||
lat: 15,
|
||||
lon: 23,
|
||||
},
|
||||
},
|
||||
blockGetStarted,
|
||||
);
|
||||
expect(result).toEqual(blockGetStarted.patterns[3]);
|
||||
});
|
||||
|
||||
it("should match payload when it's an attachment file", () => {
|
||||
const result = blockService.matchPayload(
|
||||
{
|
||||
type: PayloadType.attachments,
|
||||
attachments: {
|
||||
type: FileType.file,
|
||||
payload: {
|
||||
url: 'http://link.to/the/file',
|
||||
},
|
||||
},
|
||||
},
|
||||
blockGetStarted,
|
||||
);
|
||||
expect(result).toEqual(blockGetStarted.patterns[4]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchText', () => {
|
||||
it('should return false for matching an empty text', () => {
|
||||
const result = blockService.matchText('', blockGetStarted);
|
||||
expect(result).toEqual(false);
|
||||
});
|
||||
|
||||
it('should match text message', () => {
|
||||
const result = blockService.matchText('Hello', blockGetStarted);
|
||||
expect(result).toEqual(['Hello']);
|
||||
});
|
||||
|
||||
it('should match regex text message', () => {
|
||||
const result = blockService.matchText(
|
||||
'weeeelcome to our house',
|
||||
blockGetStarted,
|
||||
);
|
||||
expect(result).toEqualPayload(
|
||||
['weeeelcome'],
|
||||
['index', 'index', 'input', 'groups'],
|
||||
);
|
||||
});
|
||||
|
||||
it("should return false when there's no match", () => {
|
||||
const result = blockService.matchText(
|
||||
'Goodbye Mr black',
|
||||
blockGetStarted,
|
||||
);
|
||||
expect(result).toEqual(false);
|
||||
});
|
||||
|
||||
it('should return false when matching message against a block with no patterns', () => {
|
||||
const result = blockService.matchText('Hello', blockEmpty);
|
||||
expect(result).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('processMessage', () => {
|
||||
it('should process list message (with limit = 2 and skip = 0)', async () => {
|
||||
const contentType = await contentTypeService.findOne({ name: 'Product' });
|
||||
blockProductListMock.options.content.entity = contentType.id;
|
||||
const result = await blockService.processMessage(
|
||||
blockProductListMock,
|
||||
{
|
||||
...contextBlankInstance,
|
||||
skip: { [blockProductListMock.id]: 0 },
|
||||
},
|
||||
false,
|
||||
'conv_id',
|
||||
);
|
||||
const elements = await contentService.findPage(
|
||||
{ status: true, entity: contentType.id },
|
||||
{ skip: 0, limit: 2, sort: ['createdAt', 'desc'] },
|
||||
);
|
||||
const flattenedElements = elements.map((element) =>
|
||||
Content.flatDynamicFields(element),
|
||||
);
|
||||
expect(result.format).toEqualPayload(
|
||||
blockProductListMock.options.content?.display,
|
||||
);
|
||||
expect(
|
||||
(result.message as StdOutgoingListMessage).elements,
|
||||
).toEqualPayload(flattenedElements);
|
||||
expect((result.message as StdOutgoingListMessage).options).toEqualPayload(
|
||||
blockProductListMock.options.content,
|
||||
);
|
||||
expect(
|
||||
(result.message as StdOutgoingListMessage).pagination,
|
||||
).toEqualPayload({ total: 4, skip: 0, limit: 2 });
|
||||
});
|
||||
|
||||
it('should process list message (with limit = 2 and skip = 2)', async () => {
|
||||
const contentType = await contentTypeService.findOne({ name: 'Product' });
|
||||
blockProductListMock.options.content.entity = contentType.id;
|
||||
const result = await blockService.processMessage(
|
||||
blockProductListMock,
|
||||
{
|
||||
...contextBlankInstance,
|
||||
skip: { [blockProductListMock.id]: 2 },
|
||||
},
|
||||
false,
|
||||
'conv_id',
|
||||
);
|
||||
const elements = await contentService.findPage(
|
||||
{ status: true, entity: contentType.id },
|
||||
{ skip: 2, limit: 2, sort: ['createdAt', 'desc'] },
|
||||
);
|
||||
const flattenedElements = elements.map((element) =>
|
||||
Content.flatDynamicFields(element),
|
||||
);
|
||||
expect(result.format).toEqual(
|
||||
blockProductListMock.options.content?.display,
|
||||
);
|
||||
expect((result.message as StdOutgoingListMessage).elements).toEqual(
|
||||
flattenedElements,
|
||||
);
|
||||
expect((result.message as StdOutgoingListMessage).options).toEqual(
|
||||
blockProductListMock.options.content,
|
||||
);
|
||||
expect((result.message as StdOutgoingListMessage).pagination).toEqual({
|
||||
total: 4,
|
||||
skip: 2,
|
||||
limit: 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('processText', () => {
|
||||
const context: Context = {
|
||||
...contextGetStartedInstance,
|
||||
channel: 'offline',
|
||||
text: '',
|
||||
payload: undefined,
|
||||
nlp: { entities: [] },
|
||||
vars: { age: 21, email: 'email@example.com' },
|
||||
user_location: {
|
||||
address: { address: 'sangafora' },
|
||||
lat: 23,
|
||||
lon: 16,
|
||||
},
|
||||
user: subscriberWithoutLabels,
|
||||
skip: { '1': 0 },
|
||||
attempt: 0,
|
||||
};
|
||||
|
||||
it('should process empty text', () => {
|
||||
const result = blockService.processText('', context, settings);
|
||||
expect(result).toEqual('');
|
||||
});
|
||||
|
||||
it('should process text translation', () => {
|
||||
const translation = { en: 'Welcome', fr: 'Bienvenue' };
|
||||
const result = blockService.processText(
|
||||
translation.en,
|
||||
context,
|
||||
settings,
|
||||
);
|
||||
expect(result).toEqual(translation.fr);
|
||||
});
|
||||
|
||||
it('should process text replacements with ontext vars', () => {
|
||||
const result = blockService.processText(
|
||||
'{context.user.first_name} {context.user.last_name}, email : {context.vars.email}',
|
||||
contextEmailVarInstance,
|
||||
settings,
|
||||
);
|
||||
expect(result).toEqual('John Doe, email : email@example.com');
|
||||
});
|
||||
|
||||
it('should process text replacements with context vars', () => {
|
||||
const result = blockService.processText(
|
||||
'{context.user.first_name} {context.user.last_name}, email : {context.vars.email}',
|
||||
contextEmailVarInstance,
|
||||
settings,
|
||||
);
|
||||
expect(result).toEqual('John Doe, email : email@example.com');
|
||||
});
|
||||
|
||||
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,
|
||||
settings,
|
||||
);
|
||||
expect(result).toEqual(
|
||||
'Trying the settings : the name of company is <<Your company name>>',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
601
api/src/chat/services/block.service.ts
Normal file
601
api/src/chat/services/block.service.ts
Normal file
@@ -0,0 +1,601 @@
|
||||
/*
|
||||
* Copyright © 2024 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).
|
||||
* 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 { TFilterQuery } from 'mongoose';
|
||||
|
||||
import { Attachment } from '@/attachment/schemas/attachment.schema';
|
||||
import { AttachmentService } from '@/attachment/services/attachment.service';
|
||||
import EventWrapper from '@/channel/lib/EventWrapper';
|
||||
import { ContentService } from '@/cms/services/content.service';
|
||||
import { ExtendedI18nService } from '@/extended-i18n.service';
|
||||
import { LoggerService } from '@/logger/logger.service';
|
||||
import { Nlp } from '@/nlp/lib/types';
|
||||
import { PluginService } from '@/plugins/plugins.service';
|
||||
import { PluginType } from '@/plugins/types';
|
||||
import { Settings } from '@/setting/schemas/types';
|
||||
import { SettingService } from '@/setting/services/setting.service';
|
||||
import { BaseService } from '@/utils/generics/base-service';
|
||||
|
||||
import { BlockRepository } from '../repositories/block.repository';
|
||||
import { Block, BlockFull } from '../schemas/block.schema';
|
||||
import { WithUrl } from '../schemas/types/attachment';
|
||||
import { Context } from '../schemas/types/context';
|
||||
import {
|
||||
BlockMessage,
|
||||
OutgoingMessageFormat,
|
||||
StdOutgoingEnvelope,
|
||||
} from '../schemas/types/message';
|
||||
import { NlpPattern, Pattern, PayloadPattern } from '../schemas/types/pattern';
|
||||
import { Payload, StdQuickReply } from '../schemas/types/quick-reply';
|
||||
|
||||
@Injectable()
|
||||
export class BlockService extends BaseService<Block> {
|
||||
constructor(
|
||||
readonly repository: BlockRepository,
|
||||
private readonly contentService: ContentService,
|
||||
private readonly attachmentService: AttachmentService,
|
||||
private readonly settingService: SettingService,
|
||||
private readonly pluginService: PluginService,
|
||||
private readonly logger: LoggerService,
|
||||
protected readonly i18n: ExtendedI18nService,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds and populates blocks based on the specified filters.
|
||||
*
|
||||
* @param filters - Query filters used to specify search criteria for finding blocks.
|
||||
*
|
||||
* @returns A promise that resolves to the populated blocks matching the filters.
|
||||
*/
|
||||
async findAndPopulate(filters: TFilterQuery<Block>) {
|
||||
return await this.repository.findAndPopulate(filters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds and populates a block by ID.
|
||||
*
|
||||
* @param id - The block ID.
|
||||
*
|
||||
* @returns A promise that resolves to the populated block.
|
||||
*/
|
||||
async findOneAndPopulate(id: string) {
|
||||
return await this.repository.findOneAndPopulate(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a block whose patterns matches the received event
|
||||
*
|
||||
* @param blocks blocks Starting/Next blocks in the conversation flow
|
||||
* @param event Received channel's message
|
||||
*
|
||||
* @returns The block that matches
|
||||
*/
|
||||
async match(
|
||||
blocks: BlockFull[],
|
||||
event: EventWrapper<any, any>,
|
||||
): Promise<BlockFull | undefined> {
|
||||
// Search for block matching a given event
|
||||
let block: BlockFull | undefined = undefined;
|
||||
const payload = event.getPayload();
|
||||
|
||||
// Perform a filter on the specific channels
|
||||
const channel = event.getHandler().getChannel();
|
||||
blocks = blocks.filter((b) => {
|
||||
return (
|
||||
!b.trigger_channels ||
|
||||
b.trigger_channels.length === 0 ||
|
||||
b.trigger_channels.includes(channel)
|
||||
);
|
||||
});
|
||||
|
||||
// Perform a filter on trigger labels
|
||||
let userLabels: string[] = [];
|
||||
const profile = event.getSender();
|
||||
if (profile && Array.isArray(profile.labels)) {
|
||||
userLabels = profile.labels.map((l) => l);
|
||||
}
|
||||
|
||||
blocks = blocks
|
||||
.filter((b) => {
|
||||
const trigger_labels = b.trigger_labels.map(({ id }) => id);
|
||||
return (
|
||||
trigger_labels.length === 0 ||
|
||||
trigger_labels.some((l) => userLabels.includes(l))
|
||||
);
|
||||
})
|
||||
// Priority goes to block who target users with labels
|
||||
.sort((a, b) => b.trigger_labels.length - a.trigger_labels.length);
|
||||
|
||||
// Perform a payload match & pick last createdAt
|
||||
if (payload) {
|
||||
block = blocks
|
||||
.filter((b) => {
|
||||
return this.matchPayload(payload, b);
|
||||
})
|
||||
.shift();
|
||||
}
|
||||
|
||||
if (!block) {
|
||||
// Perform a text match (Text or Quick reply)
|
||||
const text = event.getText().trim();
|
||||
|
||||
// Check & catch user language through NLP
|
||||
const nlp = event.getNLP();
|
||||
if (nlp) {
|
||||
const settings = await this.settingService.getSettings();
|
||||
const lang = nlp.entities.find((e) => e.entity === 'language');
|
||||
if (
|
||||
lang &&
|
||||
settings.nlp_settings.languages.indexOf(lang.value) !== -1
|
||||
) {
|
||||
const profile = event.getSender();
|
||||
profile.language = lang.value;
|
||||
event.setSender(profile);
|
||||
}
|
||||
}
|
||||
|
||||
// Perform a text pattern match
|
||||
block = blocks
|
||||
.filter((b) => {
|
||||
return this.matchText(text, b);
|
||||
})
|
||||
.shift();
|
||||
|
||||
// Perform an NLP Match
|
||||
if (!block && nlp) {
|
||||
// Find block pattern having the best match of nlp entities
|
||||
let nlpBest = 0;
|
||||
blocks.forEach((b, index, self) => {
|
||||
const nlpPattern = this.matchNLP(nlp, b);
|
||||
if (nlpPattern && nlpPattern.length > nlpBest) {
|
||||
nlpBest = nlpPattern.length;
|
||||
block = self[index];
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
// Uknown event type => return false;
|
||||
// this.logger.error('Unable to recognize event type while matching', event);
|
||||
return block;
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a payload pattern match for the provided block
|
||||
*
|
||||
* @param payload - The payload
|
||||
* @param block - The block
|
||||
*
|
||||
* @returns The payload pattern if there's a match
|
||||
*/
|
||||
matchPayload(
|
||||
payload: string | Payload,
|
||||
block: BlockFull | Block,
|
||||
): PayloadPattern | undefined {
|
||||
const payloadPatterns = block.patterns.filter(
|
||||
(p) => typeof p === 'object' && 'label' in p,
|
||||
) as PayloadPattern[];
|
||||
|
||||
return payloadPatterns.find((pt: PayloadPattern) => {
|
||||
// Either button postback payload Or content payload (ex. BTN_TITLE:CONTENT_PAYLOAD)
|
||||
return (
|
||||
(typeof payload === 'string' &&
|
||||
pt.value &&
|
||||
(pt.value === payload || payload.startsWith(pt.value + ':'))) ||
|
||||
// Or attachment postback (ex. Like location quick reply for example)
|
||||
(typeof payload === 'object' && pt.type && pt.type === payload.type)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the block has matching text/regex patterns
|
||||
*
|
||||
* @param text - The received text message
|
||||
* @param block - The block to check against
|
||||
*
|
||||
* @returns False if no match, string/regex capture else
|
||||
*/
|
||||
matchText(
|
||||
text: string,
|
||||
block: Block | BlockFull,
|
||||
): (RegExpMatchArray | string)[] | false {
|
||||
// Filter text patterns & Instanciate Regex patterns
|
||||
const patterns: (string | RegExp | Pattern)[] = block.patterns.map(
|
||||
(pattern) => {
|
||||
if (
|
||||
typeof pattern === 'string' &&
|
||||
pattern.endsWith('/') &&
|
||||
pattern.startsWith('/')
|
||||
) {
|
||||
return new RegExp(pattern.slice(1, -1), 'i');
|
||||
}
|
||||
return pattern;
|
||||
},
|
||||
);
|
||||
|
||||
// Return first match
|
||||
for (let i = 0; i < patterns.length; i++) {
|
||||
const pattern = patterns[i];
|
||||
if (pattern instanceof RegExp) {
|
||||
if (pattern.test(text)) {
|
||||
const matches = text.match(pattern);
|
||||
if (matches) {
|
||||
if (matches.length >= 2) {
|
||||
// Remove global match if needed
|
||||
matches.shift();
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
} else if (
|
||||
typeof pattern === 'object' &&
|
||||
'label' in pattern &&
|
||||
text.trim().toLowerCase() === pattern.label.toLowerCase()
|
||||
) {
|
||||
// Payload (quick reply)
|
||||
return [text];
|
||||
} else if (
|
||||
typeof pattern === 'string' &&
|
||||
text.trim().toLowerCase() === pattern.toLowerCase()
|
||||
) {
|
||||
// Equals
|
||||
return [text];
|
||||
}
|
||||
// @deprecated
|
||||
// else if (
|
||||
// typeof pattern === 'string' &&
|
||||
// Soundex(text) === Soundex(pattern)
|
||||
// ) {
|
||||
// // Sound like
|
||||
// return [text];
|
||||
// }
|
||||
}
|
||||
// No match
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs an NLP pattern match based on the best guessed entities and/or values
|
||||
*
|
||||
* @param nlp - Parsed NLP entities
|
||||
* @param block - The block to test
|
||||
*
|
||||
* @returns The NLP patterns that matches
|
||||
*/
|
||||
matchNLP(
|
||||
nlp: Nlp.ParseEntities,
|
||||
block: Block | BlockFull,
|
||||
): NlpPattern[] | undefined {
|
||||
// No nlp entities to check against
|
||||
if (nlp.entities.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const nlpPatterns = block.patterns.filter((p) => {
|
||||
return Array.isArray(p);
|
||||
}) as NlpPattern[][];
|
||||
|
||||
// No nlp patterns found
|
||||
if (nlpPatterns.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Find NLP pattern match based on best guessed entities
|
||||
return nlpPatterns.find((entities: NlpPattern[]) => {
|
||||
return entities.every((ev: NlpPattern) => {
|
||||
if (ev.match === 'value') {
|
||||
return nlp.entities.find((e) => {
|
||||
return e.entity === ev.entity && e.value === ev.value;
|
||||
});
|
||||
} else if (ev.match === 'entity') {
|
||||
return nlp.entities.find((e) => {
|
||||
return e.entity === ev.entity;
|
||||
});
|
||||
} else {
|
||||
this.logger.warn('Block Service : Unknown NLP match type', ev);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces tokens with their context variables values in the provided text message
|
||||
*
|
||||
* `You phone number is {context.vars.phone}`
|
||||
* Becomes
|
||||
* `You phone number is 6354-543-534`
|
||||
*
|
||||
* @param text - Text message
|
||||
* @param context - Variable holding context values relative to the subscriber
|
||||
*
|
||||
* @returns Text message with the tokens being replaced
|
||||
*/
|
||||
processTokenReplacements(
|
||||
text: string,
|
||||
context: Context,
|
||||
settings: Settings,
|
||||
): string {
|
||||
// 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];
|
||||
}
|
||||
text = text.replace(
|
||||
'{context.vars.' + key + '}',
|
||||
typeof context.vars[key] === 'string'
|
||||
? context.vars[key]
|
||||
: JSON.stringify(context.vars[key]),
|
||||
);
|
||||
});
|
||||
|
||||
// Replace context tokens about user location
|
||||
if (context.user_location) {
|
||||
if (context.user_location.address) {
|
||||
const userAddress = context.user_location.address;
|
||||
Object.keys(userAddress).forEach((key) => {
|
||||
text = text.replace(
|
||||
'{context.user_location.address.' + key + '}',
|
||||
typeof userAddress[key] === 'string'
|
||||
? userAddress[key]
|
||||
: JSON.stringify(userAddress[key]),
|
||||
);
|
||||
});
|
||||
}
|
||||
text = text.replace(
|
||||
'{context.user_location.lat}',
|
||||
context.user_location.lat.toString(),
|
||||
);
|
||||
text = text.replace(
|
||||
'{context.user_location.lon}',
|
||||
context.user_location.lon.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
// Replace tokens for user infos
|
||||
Object.keys(context.user).forEach((key) => {
|
||||
const userAttr = (context.user as any)[key];
|
||||
text = text.replace(
|
||||
'{context.user.' + key + '}',
|
||||
typeof userAttr === 'string' ? userAttr : JSON.stringify(userAttr),
|
||||
);
|
||||
});
|
||||
|
||||
// Replace contact infos tokens with their values
|
||||
Object.keys(settings.contact).forEach((key) => {
|
||||
text = text.replace('{contact.' + key + '}', settings.contact[key]);
|
||||
});
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates and replaces tokens with context variables values
|
||||
*
|
||||
* @param text - Text to process
|
||||
* @param context - The context object
|
||||
*
|
||||
* @returns The text message translated and tokens being replaces with values
|
||||
*/
|
||||
processText(text: string, context: Context, settings: Settings): string {
|
||||
const lang =
|
||||
context && context.user && context.user.language
|
||||
? context.user.language
|
||||
: settings.nlp_settings.default_lang;
|
||||
// Translate
|
||||
text = this.i18n.t(text, { lang, defaultValue: text });
|
||||
// Replace context tokens
|
||||
text = this.processTokenReplacements(text, context, settings);
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a randomly picked item of the array
|
||||
*
|
||||
* @param array - Array of any type
|
||||
*
|
||||
* @returns A random item from the array
|
||||
*/
|
||||
getRandom<T>(array: T[]): T {
|
||||
return Array.isArray(array)
|
||||
? array[Math.floor(Math.random() * array.length)]
|
||||
: array;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs a warning message
|
||||
*/
|
||||
checkDeprecatedAttachmentUrl(block: Block | BlockFull) {
|
||||
if (
|
||||
block.message &&
|
||||
'attachment' in block.message &&
|
||||
'url' in block.message.attachment.payload
|
||||
) {
|
||||
this.logger.error(
|
||||
'Attachment Model : `url` payload has been deprecated in favor of `attachment_id`',
|
||||
block.id,
|
||||
block.message,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a block message based on the format.
|
||||
*
|
||||
* @param block - The block holding the message to process
|
||||
* @param context - Context object
|
||||
* @param fallback - Whenever to process main message or local fallback message
|
||||
* @param conversationId - The conversation ID
|
||||
*
|
||||
* @returns - Envelope containing message format and content following {format, message} object structure
|
||||
*/
|
||||
async processMessage(
|
||||
block: Block | BlockFull,
|
||||
context: Context,
|
||||
fallback = false,
|
||||
conversationId?: string,
|
||||
): Promise<StdOutgoingEnvelope> {
|
||||
const settings = await this.settingService.getSettings();
|
||||
const blockMessage: BlockMessage =
|
||||
fallback && block.options.fallback
|
||||
? [...block.options.fallback.message]
|
||||
: Array.isArray(block.message)
|
||||
? [...block.message]
|
||||
: { ...block.message };
|
||||
|
||||
if (Array.isArray(blockMessage)) {
|
||||
// Text Message
|
||||
// Get random message from array
|
||||
const text = this.processText(
|
||||
this.getRandom(blockMessage),
|
||||
context,
|
||||
settings,
|
||||
);
|
||||
const envelope: StdOutgoingEnvelope = {
|
||||
format: OutgoingMessageFormat.text,
|
||||
message: { text },
|
||||
};
|
||||
return envelope;
|
||||
} else if (blockMessage && 'text' in blockMessage) {
|
||||
if (
|
||||
'quickReplies' in blockMessage &&
|
||||
Array.isArray(blockMessage.quickReplies) &&
|
||||
blockMessage.quickReplies.length > 0
|
||||
) {
|
||||
const envelope: StdOutgoingEnvelope = {
|
||||
format: OutgoingMessageFormat.quickReplies,
|
||||
message: {
|
||||
text: this.processText(blockMessage.text, context, settings),
|
||||
quickReplies: blockMessage.quickReplies.map((qr: StdQuickReply) => {
|
||||
return qr.title
|
||||
? {
|
||||
...qr,
|
||||
title: this.processText(qr.title, context, settings),
|
||||
}
|
||||
: qr;
|
||||
}),
|
||||
},
|
||||
};
|
||||
return envelope;
|
||||
} else if (
|
||||
'buttons' in blockMessage &&
|
||||
Array.isArray(blockMessage.buttons) &&
|
||||
blockMessage.buttons.length > 0
|
||||
) {
|
||||
const envelope: StdOutgoingEnvelope = {
|
||||
format: OutgoingMessageFormat.buttons,
|
||||
message: {
|
||||
text: this.processText(blockMessage.text, context, settings),
|
||||
buttons: blockMessage.buttons.map((btn) => {
|
||||
return btn.title
|
||||
? {
|
||||
...btn,
|
||||
title: this.processText(btn.title, context, settings),
|
||||
}
|
||||
: btn;
|
||||
}),
|
||||
},
|
||||
};
|
||||
return envelope;
|
||||
}
|
||||
} else if (blockMessage && 'attachment' in blockMessage) {
|
||||
const attachmentPayload = blockMessage.attachment.payload;
|
||||
if (!attachmentPayload.attachment_id) {
|
||||
this.checkDeprecatedAttachmentUrl(block);
|
||||
throw new Error('Remote attachments are no longer supported!');
|
||||
}
|
||||
|
||||
const attachment = await this.attachmentService.findOne(
|
||||
attachmentPayload.attachment_id,
|
||||
);
|
||||
|
||||
if (!attachment) {
|
||||
this.logger.debug(
|
||||
'Unable to locate the attachment for the given block',
|
||||
block,
|
||||
);
|
||||
throw new Error('Unable to find attachment.');
|
||||
}
|
||||
|
||||
const envelope: StdOutgoingEnvelope = {
|
||||
format: OutgoingMessageFormat.attachment,
|
||||
message: {
|
||||
attachment: {
|
||||
type: blockMessage.attachment.type,
|
||||
payload: attachment as WithUrl<Attachment>,
|
||||
},
|
||||
quickReplies: blockMessage.quickReplies
|
||||
? [...blockMessage.quickReplies]
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
return envelope;
|
||||
} else if (
|
||||
blockMessage &&
|
||||
'elements' in blockMessage &&
|
||||
block.options.content
|
||||
) {
|
||||
const contentBlockOptions = block.options.content;
|
||||
// Hadnle pagination for list/carousel
|
||||
let skip = 0;
|
||||
if (
|
||||
contentBlockOptions.display === OutgoingMessageFormat.list ||
|
||||
contentBlockOptions.display === OutgoingMessageFormat.carousel
|
||||
) {
|
||||
skip =
|
||||
context.skip && context.skip[block.id] ? context.skip[block.id] : 0;
|
||||
}
|
||||
// Populate list with content
|
||||
try {
|
||||
const results = await this.contentService.getContent(
|
||||
contentBlockOptions,
|
||||
skip,
|
||||
);
|
||||
|
||||
const envelope: StdOutgoingEnvelope = {
|
||||
format: contentBlockOptions.display,
|
||||
message: {
|
||||
...results,
|
||||
options: contentBlockOptions,
|
||||
},
|
||||
};
|
||||
|
||||
return envelope;
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
'Unable to retrieve content for list template process',
|
||||
err,
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
} else if (blockMessage && 'plugin' in blockMessage) {
|
||||
const plugin = this.pluginService.findPlugin(
|
||||
PluginType.block,
|
||||
blockMessage.plugin,
|
||||
);
|
||||
// Process custom plugin block
|
||||
try {
|
||||
return await plugin.process(block, context, conversationId);
|
||||
} catch (e) {
|
||||
this.logger.error('Plugin was unable to load/process ', e);
|
||||
throw new Error(`Unknown plugin - ${JSON.stringify(blockMessage)}`);
|
||||
}
|
||||
} else {
|
||||
throw new Error('Invalid message format.');
|
||||
}
|
||||
}
|
||||
}
|
||||
329
api/src/chat/services/bot.service.spec.ts
Normal file
329
api/src/chat/services/bot.service.spec.ts
Normal file
@@ -0,0 +1,329 @@
|
||||
/*
|
||||
* Copyright © 2024 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).
|
||||
* 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 { CACHE_MANAGER } from '@nestjs/cache-manager';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { MongooseModule } from '@nestjs/mongoose';
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import { AttachmentRepository } from '@/attachment/repositories/attachment.repository';
|
||||
import { AttachmentModel } from '@/attachment/schemas/attachment.schema';
|
||||
import { AttachmentService } from '@/attachment/services/attachment.service';
|
||||
import { ChannelService } from '@/channel/channel.service';
|
||||
import { ContentTypeRepository } from '@/cms/repositories/content-type.repository';
|
||||
import { ContentRepository } from '@/cms/repositories/content.repository';
|
||||
import { MenuRepository } from '@/cms/repositories/menu.repository';
|
||||
import { ContentTypeModel } from '@/cms/schemas/content-type.schema';
|
||||
import { ContentModel } from '@/cms/schemas/content.schema';
|
||||
import { MenuModel } from '@/cms/schemas/menu.schema';
|
||||
import { ContentTypeService } from '@/cms/services/content-type.service';
|
||||
import { ContentService } from '@/cms/services/content.service';
|
||||
import { MenuService } from '@/cms/services/menu.service';
|
||||
import { ExtendedI18nService } from '@/extended-i18n.service';
|
||||
import { offlineEventText } from '@/extensions/channels/offline/__test__/events.mock';
|
||||
import OfflineHandler from '@/extensions/channels/offline/index.channel';
|
||||
import OfflineEventWrapper from '@/extensions/channels/offline/wrapper';
|
||||
import { LoggerService } from '@/logger/logger.service';
|
||||
import { NlpEntityRepository } from '@/nlp/repositories/nlp-entity.repository';
|
||||
import { NlpSampleEntityRepository } from '@/nlp/repositories/nlp-sample-entity.repository';
|
||||
import { NlpSampleRepository } from '@/nlp/repositories/nlp-sample.repository';
|
||||
import { NlpValueRepository } from '@/nlp/repositories/nlp-value.repository';
|
||||
import { NlpEntityModel } from '@/nlp/schemas/nlp-entity.schema';
|
||||
import { NlpSampleEntityModel } from '@/nlp/schemas/nlp-sample-entity.schema';
|
||||
import { NlpSampleModel } from '@/nlp/schemas/nlp-sample.schema';
|
||||
import { NlpValueModel } from '@/nlp/schemas/nlp-value.schema';
|
||||
import { NlpEntityService } from '@/nlp/services/nlp-entity.service';
|
||||
import { NlpSampleEntityService } from '@/nlp/services/nlp-sample-entity.service';
|
||||
import { NlpSampleService } from '@/nlp/services/nlp-sample.service';
|
||||
import { NlpValueService } from '@/nlp/services/nlp-value.service';
|
||||
import { NlpService } from '@/nlp/services/nlp.service';
|
||||
import { PluginService } from '@/plugins/plugins.service';
|
||||
import { SettingService } from '@/setting/services/setting.service';
|
||||
import { installBlockFixtures } from '@/utils/test/fixtures/block';
|
||||
import { installContentFixtures } from '@/utils/test/fixtures/content';
|
||||
import { installSubscriberFixtures } from '@/utils/test/fixtures/subscriber';
|
||||
import {
|
||||
closeInMongodConnection,
|
||||
rootMongooseTestModule,
|
||||
} from '@/utils/test/test';
|
||||
import { SocketEventDispatcherService } from '@/websocket/services/socket-event-dispatcher.service';
|
||||
import { WebsocketGateway } from '@/websocket/websocket.gateway';
|
||||
|
||||
import { CategoryRepository } from './../repositories/category.repository';
|
||||
import { BlockService } from './block.service';
|
||||
import { BotService } from './bot.service';
|
||||
import { CategoryService } from './category.service';
|
||||
import { ConversationService } from './conversation.service';
|
||||
import { MessageService } from './message.service';
|
||||
import { SubscriberService } from './subscriber.service';
|
||||
import { BlockRepository } from '../repositories/block.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 {
|
||||
Conversation,
|
||||
ConversationFull,
|
||||
ConversationModel,
|
||||
} from '../schemas/conversation.schema';
|
||||
import { LabelModel } from '../schemas/label.schema';
|
||||
import { MessageModel } from '../schemas/message.schema';
|
||||
import { SubscriberModel } from '../schemas/subscriber.schema';
|
||||
|
||||
describe('BlockService', () => {
|
||||
let blockService: BlockService;
|
||||
let subscriberService: SubscriberService;
|
||||
let botService: BotService;
|
||||
let handler: OfflineHandler;
|
||||
let eventEmitter: EventEmitter2;
|
||||
|
||||
beforeAll(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
imports: [
|
||||
rootMongooseTestModule(async () => {
|
||||
await installSubscriberFixtures();
|
||||
await installContentFixtures();
|
||||
await installBlockFixtures();
|
||||
}),
|
||||
MongooseModule.forFeature([
|
||||
BlockModel,
|
||||
CategoryModel,
|
||||
ContentTypeModel,
|
||||
ContentModel,
|
||||
AttachmentModel,
|
||||
LabelModel,
|
||||
ConversationModel,
|
||||
SubscriberModel,
|
||||
MessageModel,
|
||||
MenuModel,
|
||||
NlpValueModel,
|
||||
NlpEntityModel,
|
||||
NlpSampleEntityModel,
|
||||
NlpSampleModel,
|
||||
]),
|
||||
],
|
||||
providers: [
|
||||
EventEmitter2,
|
||||
BlockRepository,
|
||||
CategoryRepository,
|
||||
WebsocketGateway,
|
||||
SocketEventDispatcherService,
|
||||
ConversationRepository,
|
||||
ContentTypeRepository,
|
||||
ContentRepository,
|
||||
AttachmentRepository,
|
||||
SubscriberRepository,
|
||||
MessageRepository,
|
||||
MenuRepository,
|
||||
NlpValueRepository,
|
||||
NlpEntityRepository,
|
||||
NlpSampleEntityRepository,
|
||||
NlpSampleRepository,
|
||||
BlockService,
|
||||
CategoryService,
|
||||
ContentTypeService,
|
||||
ContentService,
|
||||
AttachmentService,
|
||||
SubscriberService,
|
||||
ConversationService,
|
||||
BotService,
|
||||
ChannelService,
|
||||
MessageService,
|
||||
MenuService,
|
||||
OfflineHandler,
|
||||
NlpValueService,
|
||||
NlpEntityService,
|
||||
NlpSampleEntityService,
|
||||
NlpSampleService,
|
||||
NlpService,
|
||||
{
|
||||
provide: PluginService,
|
||||
useValue: {},
|
||||
},
|
||||
LoggerService,
|
||||
{
|
||||
provide: ExtendedI18nService,
|
||||
useValue: {
|
||||
t: jest.fn().mockImplementation((t) => t),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: SettingService,
|
||||
useValue: {
|
||||
getConfig: jest.fn(() => ({
|
||||
chatbot: { lang: { default: 'fr' } },
|
||||
})),
|
||||
getSettings: jest.fn(() => ({
|
||||
contact: { company_name: 'Your company name' },
|
||||
})),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: CACHE_MANAGER,
|
||||
useValue: {
|
||||
del: jest.fn(),
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
subscriberService = module.get<SubscriberService>(SubscriberService);
|
||||
botService = module.get<BotService>(BotService);
|
||||
blockService = module.get<BlockService>(BlockService);
|
||||
eventEmitter = module.get<EventEmitter2>(EventEmitter2);
|
||||
handler = module.get<OfflineHandler>(OfflineHandler);
|
||||
});
|
||||
|
||||
afterEach(jest.clearAllMocks);
|
||||
afterAll(closeInMongodConnection);
|
||||
|
||||
it('should start a conversation', async () => {
|
||||
const triggeredEvents: any[] = [];
|
||||
|
||||
eventEmitter.on('hook:stats:entry', (...args) => {
|
||||
triggeredEvents.push(args);
|
||||
});
|
||||
|
||||
const event = new OfflineEventWrapper(handler, offlineEventText, {
|
||||
isSocket: false,
|
||||
ipAddress: '1.1.1.1',
|
||||
});
|
||||
|
||||
const [block] = await blockService.findAndPopulate({ patterns: ['Hi'] });
|
||||
const offlineSubscriber = await subscriberService.findOne({
|
||||
foreign_id: 'foreign-id-offline-1',
|
||||
});
|
||||
|
||||
event.setSender(offlineSubscriber);
|
||||
|
||||
let hasBotSpoken = false;
|
||||
const clearMock = jest
|
||||
.spyOn(botService, 'findBlockAndSendReply')
|
||||
.mockImplementation(
|
||||
(
|
||||
actualEvent: OfflineEventWrapper,
|
||||
actualConversation: Conversation,
|
||||
actualBlock: BlockFull,
|
||||
isFallback: boolean,
|
||||
) => {
|
||||
expect(actualConversation).toEqualPayload({
|
||||
sender: offlineSubscriber.id,
|
||||
active: true,
|
||||
next: [],
|
||||
context: {
|
||||
user: {
|
||||
first_name: offlineSubscriber.first_name,
|
||||
last_name: offlineSubscriber.last_name,
|
||||
language: 'en',
|
||||
id: offlineSubscriber.id,
|
||||
},
|
||||
user_location: {
|
||||
lat: 0,
|
||||
lon: 0,
|
||||
},
|
||||
vars: {},
|
||||
nlp: null,
|
||||
payload: null,
|
||||
attempt: 0,
|
||||
channel: 'offline',
|
||||
text: offlineEventText.data.text,
|
||||
},
|
||||
});
|
||||
expect(actualEvent).toEqual(event);
|
||||
expect(actualBlock).toEqual(block);
|
||||
expect(isFallback).toEqual(false);
|
||||
hasBotSpoken = true;
|
||||
},
|
||||
);
|
||||
|
||||
await botService.startConversation(event, block);
|
||||
expect(hasBotSpoken).toEqual(true);
|
||||
expect(triggeredEvents).toEqual([
|
||||
['popular', 'hasNextBlocks'],
|
||||
['new_conversations', 'New conversations'],
|
||||
]);
|
||||
clearMock.mockClear();
|
||||
});
|
||||
|
||||
it('should capture a conversation', async () => {
|
||||
const triggeredEvents: any[] = [];
|
||||
|
||||
eventEmitter.on('hook:stats:entry', (...args) => {
|
||||
triggeredEvents.push(args);
|
||||
});
|
||||
|
||||
const event = new OfflineEventWrapper(handler, offlineEventText, {
|
||||
isSocket: false,
|
||||
ipAddress: '1.1.1.1',
|
||||
});
|
||||
const offlineSubscriber = await subscriberService.findOne({
|
||||
foreign_id: 'foreign-id-offline-1',
|
||||
});
|
||||
event.setSender(offlineSubscriber);
|
||||
|
||||
const clearMock = jest
|
||||
.spyOn(botService, 'handleIncomingMessage')
|
||||
.mockImplementation(
|
||||
async (
|
||||
actualConversation: ConversationFull,
|
||||
event: OfflineEventWrapper,
|
||||
) => {
|
||||
expect(actualConversation).toEqualPayload({
|
||||
next: [],
|
||||
sender: offlineSubscriber,
|
||||
active: true,
|
||||
context: {
|
||||
user: {
|
||||
first_name: offlineSubscriber.first_name,
|
||||
last_name: offlineSubscriber.last_name,
|
||||
language: 'en',
|
||||
id: offlineSubscriber.id,
|
||||
},
|
||||
user_location: { lat: 0, lon: 0 },
|
||||
vars: {},
|
||||
nlp: null,
|
||||
payload: null,
|
||||
attempt: 0,
|
||||
channel: 'offline',
|
||||
text: offlineEventText.data.text,
|
||||
},
|
||||
});
|
||||
expect(event).toEqual(event);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
const captured = await botService.processConversationMessage(event);
|
||||
expect(captured).toBe(true);
|
||||
expect(triggeredEvents).toEqual([
|
||||
['existing_conversations', 'Existing conversations'],
|
||||
]);
|
||||
clearMock.mockClear();
|
||||
});
|
||||
|
||||
it('has no active conversation', async () => {
|
||||
const triggeredEvents: any[] = [];
|
||||
eventEmitter.on('hook:stats:entry', (...args) => {
|
||||
triggeredEvents.push(args);
|
||||
});
|
||||
const event = new OfflineEventWrapper(handler, offlineEventText, {
|
||||
isSocket: false,
|
||||
ipAddress: '1.1.1.1',
|
||||
});
|
||||
const offlineSubscriber = await subscriberService.findOne({
|
||||
foreign_id: 'foreign-id-offline-2',
|
||||
});
|
||||
event.setSender(offlineSubscriber);
|
||||
const captured = await botService.processConversationMessage(event);
|
||||
|
||||
expect(captured).toBe(false);
|
||||
expect(triggeredEvents).toEqual([]);
|
||||
});
|
||||
});
|
||||
506
api/src/chat/services/bot.service.ts
Normal file
506
api/src/chat/services/bot.service.ts
Normal file
@@ -0,0 +1,506 @@
|
||||
/*
|
||||
* Copyright © 2024 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).
|
||||
* 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 { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
|
||||
import EventWrapper from '@/channel/lib/EventWrapper';
|
||||
import { LoggerService } from '@/logger/logger.service';
|
||||
import { Settings } from '@/setting/schemas/types';
|
||||
import { SettingService } from '@/setting/services/setting.service';
|
||||
|
||||
import { BlockService } from './block.service';
|
||||
import { ConversationService } from './conversation.service';
|
||||
import { SubscriberService } from './subscriber.service';
|
||||
import { MessageCreateDto } from '../dto/message.dto';
|
||||
import { BlockFull } from '../schemas/block.schema';
|
||||
import {
|
||||
Conversation,
|
||||
ConversationFull,
|
||||
getDefaultConversationContext,
|
||||
} from '../schemas/conversation.schema';
|
||||
import { Context } from '../schemas/types/context';
|
||||
import {
|
||||
IncomingMessageType,
|
||||
StdOutgoingEnvelope,
|
||||
} from '../schemas/types/message';
|
||||
|
||||
@Injectable()
|
||||
export class BotService {
|
||||
constructor(
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
private readonly logger: LoggerService,
|
||||
private readonly blockService: BlockService,
|
||||
private readonly conversationService: ConversationService,
|
||||
private readonly subscriberService: SubscriberService,
|
||||
private readonly settingService: SettingService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Sends a processed message to the user based on a specified content block.
|
||||
* Replaces tokens within the block with context data, handles fallback scenarios,
|
||||
* and assigns relevant labels to the user.
|
||||
*
|
||||
* @param event - The incoming message or action that triggered the bot's response.
|
||||
* @param block - The content block containing the message and options to be sent.
|
||||
* @param context - Optional. The conversation context object, containing relevant data for personalization.
|
||||
* @param fallback - Optional. Boolean flag indicating if this is a fallback message when no appropriate response was found.
|
||||
* @param conversationId - Optional. The conversation ID to link the message to a specific conversation thread.
|
||||
*
|
||||
* @returns A promise that resolves with the message response, including the message ID.
|
||||
*/
|
||||
async sendMessageToSubscriber(
|
||||
event: EventWrapper<any, any>,
|
||||
block: BlockFull,
|
||||
context?: Context,
|
||||
fallback?: boolean,
|
||||
conservationId?: string,
|
||||
) {
|
||||
context = context || getDefaultConversationContext();
|
||||
fallback = typeof fallback !== 'undefined' ? fallback : false;
|
||||
const options = block.options;
|
||||
this.logger.debug(
|
||||
'Bot service : Sending message ... ',
|
||||
event.getSenderForeignId(),
|
||||
);
|
||||
// Process message : Replace tokens with context data and then send the message
|
||||
const envelope: StdOutgoingEnvelope =
|
||||
await this.blockService.processMessage(
|
||||
block,
|
||||
context,
|
||||
fallback,
|
||||
conservationId,
|
||||
);
|
||||
// Send message through the right channel
|
||||
|
||||
const response = await event
|
||||
.getHandler()
|
||||
.sendMessage(event, envelope, options, context);
|
||||
|
||||
this.eventEmitter.emit('hook:stats:entry', 'outgoing', 'Outgoing');
|
||||
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,
|
||||
recipient: recipient.id,
|
||||
handover: !!(options && options.assignTo),
|
||||
read: false,
|
||||
delivery: false,
|
||||
};
|
||||
this.eventEmitter.emit('hook:chatbot:sent', sentMessage, event);
|
||||
|
||||
// analytics log block or local fallback
|
||||
if (fallback) {
|
||||
this.eventEmitter.emit(
|
||||
'hook:analytics:fallback-local',
|
||||
block,
|
||||
event,
|
||||
context,
|
||||
);
|
||||
} else {
|
||||
this.eventEmitter.emit('hook:analytics:block', block, event, context);
|
||||
}
|
||||
|
||||
// Apply updates : Assign block labels to user
|
||||
const blockLabels = (block.assign_labels || []).map(({ id }) => id);
|
||||
const assignTo = block.options.assignTo || null;
|
||||
await this.subscriberService.applyUpdates(
|
||||
event.getSender(),
|
||||
blockLabels,
|
||||
assignTo,
|
||||
);
|
||||
|
||||
this.logger.debug('Bot service : Assigned labels ', blockLabels);
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds an appropriate reply block and sends it to the user.
|
||||
* If there are additional blocks or attached blocks, it continues the conversation flow.
|
||||
* Ends the conversation if no further blocks are available.
|
||||
*
|
||||
* @param event - The incoming message or action that initiated this response.
|
||||
* @param convo - The current conversation context and flow.
|
||||
* @param block - The content block to be processed and sent.
|
||||
* @param fallback - Boolean indicating if this is a fallback response in case no appropriate reply was found.
|
||||
*
|
||||
* @returns A promise that continues or ends the conversation based on available blocks.
|
||||
*/
|
||||
async findBlockAndSendReply(
|
||||
event: EventWrapper<any, any>,
|
||||
convo: Conversation,
|
||||
block: BlockFull,
|
||||
fallback: boolean,
|
||||
) {
|
||||
try {
|
||||
await this.sendMessageToSubscriber(
|
||||
event,
|
||||
block,
|
||||
convo.context,
|
||||
fallback,
|
||||
convo.id,
|
||||
);
|
||||
if (block.attachedBlock) {
|
||||
// Sequential messaging ?
|
||||
try {
|
||||
const attachedBlock = await this.blockService.findOneAndPopulate(
|
||||
block.attachedBlock.id,
|
||||
);
|
||||
if (!attachedBlock) {
|
||||
throw new Error(
|
||||
'No attached block to be found with id ' + block.attachedBlock,
|
||||
);
|
||||
}
|
||||
return await this.findBlockAndSendReply(
|
||||
event,
|
||||
convo,
|
||||
attachedBlock,
|
||||
fallback,
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error('Unable to retrieve attached block', err);
|
||||
this.eventEmitter.emit('hook:conversation:end', convo, true);
|
||||
}
|
||||
} else if (
|
||||
Array.isArray(block.nextBlocks) &&
|
||||
block.nextBlocks.length > 0
|
||||
) {
|
||||
// Conversation continues : Go forward to next blocks
|
||||
this.logger.debug('Conversation continues ...', convo.id);
|
||||
const nextIds = block.nextBlocks.map(({ id }) => id);
|
||||
try {
|
||||
await this.conversationService.updateOne(convo.id, {
|
||||
current: block.id,
|
||||
next: nextIds,
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
'Unable to update conversation when going next',
|
||||
convo,
|
||||
err,
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// We need to end the conversation in this case
|
||||
this.logger.debug('No attached/next blocks to execute ...');
|
||||
this.eventEmitter.emit('hook:conversation:end', convo, false);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error('Unable to process/send message.', err);
|
||||
this.eventEmitter.emit('hook:conversation:end', convo, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes and responds to an incoming message within an ongoing conversation flow.
|
||||
* Determines the next block in the conversation, attempts to match the message with available blocks,
|
||||
* and handles fallback scenarios if no match is found.
|
||||
*
|
||||
* @param convo - The current conversation object, representing the flow and context of the dialogue.
|
||||
* @param event - The incoming message or action that triggered this response.
|
||||
*
|
||||
* @returns A promise that resolves with a boolean indicating whether the conversation is active and a matching block was found.
|
||||
*/
|
||||
async handleIncomingMessage(
|
||||
convo: ConversationFull,
|
||||
event: EventWrapper<any, any>,
|
||||
) {
|
||||
const nextIds = convo.next.map(({ id }) => id);
|
||||
// Reload blocks in order to populate his nextBlocks
|
||||
// nextBlocks & trigger/assign _labels
|
||||
try {
|
||||
const nextBlocks = await this.blockService.findAndPopulate({
|
||||
_id: { $in: nextIds },
|
||||
});
|
||||
let fallback = false;
|
||||
const fallbackOptions =
|
||||
convo.current && convo.current.options.fallback
|
||||
? convo.current.options.fallback
|
||||
: {
|
||||
active: false,
|
||||
max_attempts: 0,
|
||||
};
|
||||
|
||||
// Find the next block that matches
|
||||
const matchedBlock = await this.blockService.match(nextBlocks, event);
|
||||
// If there is no match in next block then loopback (current fallback)
|
||||
// This applies only to text messages + there's a max attempt to be specified
|
||||
let fallbackBlock: BlockFull | undefined;
|
||||
if (
|
||||
!matchedBlock &&
|
||||
event.getMessageType() === IncomingMessageType.message &&
|
||||
fallbackOptions.active &&
|
||||
convo.context.attempt < fallbackOptions.max_attempts
|
||||
) {
|
||||
// Trigger block fallback
|
||||
// NOTE : current is not populated, this may cause some anomaly
|
||||
const currentBlock = convo.current;
|
||||
fallbackBlock = {
|
||||
...currentBlock,
|
||||
nextBlocks: convo.next,
|
||||
// If there's labels, they should be already have been assigned
|
||||
assign_labels: [],
|
||||
trigger_labels: [],
|
||||
attachedBlock: undefined,
|
||||
category: undefined,
|
||||
previousBlocks: [],
|
||||
};
|
||||
convo.context.attempt++;
|
||||
fallback = true;
|
||||
} else {
|
||||
convo.context.attempt = 0;
|
||||
fallbackBlock = undefined;
|
||||
}
|
||||
|
||||
const next = matchedBlock || fallbackBlock;
|
||||
|
||||
this.logger.debug('Responding ...', convo.id);
|
||||
|
||||
if (next) {
|
||||
// Increment stats about popular blocks
|
||||
this.eventEmitter.emit('hook:stats:entry', 'popular', next.name);
|
||||
// Go next!
|
||||
this.logger.debug('Respond to nested conversion! Go next ', next.id);
|
||||
try {
|
||||
const updatedConversation =
|
||||
await this.conversationService.storeContextData(
|
||||
convo,
|
||||
next,
|
||||
event,
|
||||
// If this is a local fallback then we don't capture vars
|
||||
// Otherwise, old captured const value may be replaced by another const value
|
||||
!fallback,
|
||||
);
|
||||
await this.findBlockAndSendReply(
|
||||
event,
|
||||
updatedConversation,
|
||||
next,
|
||||
fallback,
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error('Unable to store context data!', err);
|
||||
return this.eventEmitter.emit('hook:conversation:end', convo, true);
|
||||
}
|
||||
return true;
|
||||
} else {
|
||||
// Conversation is still active, but there's no matching block to call next
|
||||
// We'll end the conversation but this message is probably lost in time and space.
|
||||
this.logger.debug('No matching block found to call next ', convo.id);
|
||||
this.eventEmitter.emit('hook:conversation:end', convo, false);
|
||||
return false;
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error('Unable to populate the next blocks!', err);
|
||||
this.eventEmitter.emit('hook:conversation:end', convo, true);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the incoming message belongs to an active conversation and processes it accordingly.
|
||||
* If an active conversation is found, the message is handled as part of that conversation.
|
||||
*
|
||||
* @param event - The incoming message or action from the subscriber.
|
||||
*
|
||||
* @returns A promise that resolves with the conversation's response or false if no active conversation is found.
|
||||
*/
|
||||
async processConversationMessage(event: EventWrapper<any, any>) {
|
||||
this.logger.debug(
|
||||
'Is this message apart of an active conversation ? Searching ... ',
|
||||
);
|
||||
const subscriber = event.getSender();
|
||||
try {
|
||||
const conversation = await this.conversationService.findOneAndPopulate({
|
||||
sender: subscriber.id,
|
||||
active: true,
|
||||
});
|
||||
// No active conversation found
|
||||
if (!conversation) {
|
||||
this.logger.debug('No active conversation found ', subscriber.id);
|
||||
return false;
|
||||
}
|
||||
|
||||
this.eventEmitter.emit(
|
||||
'hook:stats:entry',
|
||||
'existing_conversations',
|
||||
'Existing conversations',
|
||||
);
|
||||
this.logger.debug('Conversation has been captured! Responding ...');
|
||||
return await this.handleIncomingMessage(conversation, event);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
'An error occured when searching for a conversation ',
|
||||
err,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new conversation starting from a given block (entrypoint)
|
||||
*
|
||||
* @param event - Incoming message/action
|
||||
* @param block - Starting block
|
||||
*/
|
||||
async startConversation(event: EventWrapper<any, any>, block: BlockFull) {
|
||||
// Increment popular stats
|
||||
this.eventEmitter.emit('hook:stats:entry', 'popular', block.name);
|
||||
// Launching a new conversation
|
||||
const subscriber = event.getSender();
|
||||
|
||||
try {
|
||||
const convo = await this.conversationService.create({
|
||||
sender: subscriber.id,
|
||||
});
|
||||
this.eventEmitter.emit(
|
||||
'hook:stats:entry',
|
||||
'new_conversations',
|
||||
'New conversations',
|
||||
);
|
||||
|
||||
try {
|
||||
const updatedConversation =
|
||||
await this.conversationService.storeContextData(
|
||||
convo,
|
||||
block,
|
||||
event,
|
||||
true,
|
||||
);
|
||||
|
||||
this.logger.debug(
|
||||
'Bot service : Started a new conversation with ',
|
||||
subscriber.id,
|
||||
block.name,
|
||||
);
|
||||
return this.findBlockAndSendReply(
|
||||
event,
|
||||
updatedConversation,
|
||||
block,
|
||||
false,
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error('Bot service : Unable to store context data!', err);
|
||||
this.eventEmitter.emit('hook:conversation:end', convo, true);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
'Botservice : Unable to start a new conversation with ',
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return global fallback block
|
||||
*
|
||||
* @param settings - The app settings
|
||||
*
|
||||
* @returns The global fallback block
|
||||
*/
|
||||
async getGlobalFallbackBlock(settings: Settings) {
|
||||
const chatbot_settings = settings.chatbot_settings;
|
||||
if (chatbot_settings.fallback_block) {
|
||||
const block = await this.blockService.findOneAndPopulate(
|
||||
chatbot_settings.fallback_block,
|
||||
);
|
||||
|
||||
if (!block) {
|
||||
throw new Error('Unable to retrieve global fallback block.');
|
||||
}
|
||||
|
||||
return block;
|
||||
}
|
||||
throw new Error('No global fallback block is defined.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes incoming message event from a given channel
|
||||
*
|
||||
* @param event - Incoming message/action
|
||||
*/
|
||||
async handleMessageEvent(event: EventWrapper<any, any>) {
|
||||
const settings = await this.settingService.getSettings();
|
||||
try {
|
||||
const captured = await this.processConversationMessage(event);
|
||||
if (captured) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Search for entry blocks
|
||||
try {
|
||||
const blocks = await this.blockService.findAndPopulate({
|
||||
starts_conversation: true,
|
||||
});
|
||||
|
||||
if (!blocks.length) {
|
||||
return this.logger.debug('No starting message blocks was found');
|
||||
}
|
||||
|
||||
// Search for a block match
|
||||
const block = await this.blockService.match(blocks, event);
|
||||
|
||||
// No block match
|
||||
if (!block) {
|
||||
this.logger.debug('No message blocks available!');
|
||||
if (
|
||||
settings.chatbot_settings &&
|
||||
settings.chatbot_settings.global_fallback
|
||||
) {
|
||||
this.eventEmitter.emit('hook:analytics:fallback-global', event);
|
||||
this.logger.debug('Sending global fallback message ...');
|
||||
// If global fallback is defined in a block then launch a new conversation
|
||||
// Otherwise, send a simple text message as defined in global settings
|
||||
try {
|
||||
const fallbackBlock = await this.getGlobalFallbackBlock(settings);
|
||||
return this.startConversation(event, fallbackBlock);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
'No global fallback block defined, sending a message ...',
|
||||
err,
|
||||
);
|
||||
this.sendMessageToSubscriber(event, {
|
||||
id: 'global-fallback',
|
||||
name: 'Global Fallback',
|
||||
message: settings.chatbot_settings.fallback_message,
|
||||
options: {},
|
||||
patterns: [],
|
||||
assign_labels: [],
|
||||
starts_conversation: false,
|
||||
position: { x: 0, y: 0 },
|
||||
capture_vars: [],
|
||||
builtin: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
attachedBlock: null,
|
||||
} as any as BlockFull);
|
||||
}
|
||||
}
|
||||
// Do nothing ...
|
||||
return;
|
||||
}
|
||||
|
||||
this.startConversation(event, block);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
'An error occured while retrieving starting message blocks ',
|
||||
err,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.debug(
|
||||
'Either something went wrong, no active conservation was found or user changed subject',
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
22
api/src/chat/services/category.service.ts
Normal file
22
api/src/chat/services/category.service.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Copyright © 2024 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).
|
||||
* 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 { BaseService } from '@/utils/generics/base-service';
|
||||
|
||||
import { CategoryRepository } from '../repositories/category.repository';
|
||||
import { Category } from '../schemas/category.schema';
|
||||
|
||||
@Injectable()
|
||||
export class CategoryService extends BaseService<Category> {
|
||||
constructor(readonly repository: CategoryRepository) {
|
||||
super(repository);
|
||||
}
|
||||
}
|
||||
305
api/src/chat/services/chat.service.ts
Normal file
305
api/src/chat/services/chat.service.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
/*
|
||||
* Copyright © 2024 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).
|
||||
* 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 { EventEmitter2, OnEvent } from '@nestjs/event-emitter';
|
||||
|
||||
import EventWrapper from '@/channel/lib/EventWrapper';
|
||||
import { config } from '@/config';
|
||||
import { LoggerService } from '@/logger/logger.service';
|
||||
import { NlpService } from '@/nlp/services/nlp.service';
|
||||
import { WebsocketGateway } from '@/websocket/websocket.gateway';
|
||||
|
||||
import { BotService } from './bot.service';
|
||||
import { ConversationService } from './conversation.service';
|
||||
import { MessageService } from './message.service';
|
||||
import { SubscriberService } from './subscriber.service';
|
||||
import { MessageCreateDto } from '../dto/message.dto';
|
||||
import { Conversation } from '../schemas/conversation.schema';
|
||||
import { Subscriber } from '../schemas/subscriber.schema';
|
||||
import { OutgoingMessage } from '../schemas/types/message';
|
||||
|
||||
@Injectable()
|
||||
export class ChatService {
|
||||
constructor(
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
private readonly logger: LoggerService,
|
||||
private readonly conversationService: ConversationService,
|
||||
private readonly messageService: MessageService,
|
||||
private readonly subscriberService: SubscriberService,
|
||||
private readonly botService: BotService,
|
||||
private readonly websocketGateway: WebsocketGateway,
|
||||
private readonly nlpService: NlpService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Ends a given conversation (sets active to false)
|
||||
*
|
||||
* @param convo - The conversation to end
|
||||
*/
|
||||
@OnEvent('hook:conversation:end')
|
||||
async handleEndConversation(convo: Conversation) {
|
||||
try {
|
||||
await this.conversationService.end(convo);
|
||||
this.logger.debug('Conversation has ended successfully.', convo.id);
|
||||
} catch (err) {
|
||||
this.logger.error('Unable to end conversation !', convo.id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends a given conversation (sets active to false)
|
||||
*
|
||||
* @param convoId - The conversation ID
|
||||
*/
|
||||
@OnEvent('hook:conversation:close')
|
||||
async handleCloseConversation(convoId: string) {
|
||||
try {
|
||||
await this.conversationService.deleteOne(convoId);
|
||||
this.logger.debug('Conversation is closed successfully.', convoId);
|
||||
} catch (err) {
|
||||
this.logger.error('Unable to close conversation.', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds or creates a message and broadcast it to the websocket "Message" room
|
||||
*
|
||||
* @param sentMessage - The message that has been sent
|
||||
*/
|
||||
@OnEvent('hook:chatbot:sent')
|
||||
async handleSentMessage(sentMessage: MessageCreateDto) {
|
||||
if (sentMessage.mid) {
|
||||
try {
|
||||
const message = await this.messageService.findOneOrCreate(
|
||||
{
|
||||
mid: sentMessage.mid,
|
||||
},
|
||||
sentMessage,
|
||||
);
|
||||
this.websocketGateway.broadcastMessageSent(message as OutgoingMessage);
|
||||
this.logger.debug('Message has been logged.', sentMessage.mid);
|
||||
} catch (err) {
|
||||
this.logger.error('Unable to log sent message.', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the received message and broadcast it to the websocket "Message" room
|
||||
*
|
||||
* @param event - The received event
|
||||
*/
|
||||
@OnEvent('hook:chatbot:received')
|
||||
async handleReceivedMessage(event: EventWrapper<any, any>) {
|
||||
let messageId = '';
|
||||
this.logger.debug('Received message', event);
|
||||
try {
|
||||
messageId = event.getId();
|
||||
} catch (err) {
|
||||
this.logger.warn('Failed to get the event id', messageId);
|
||||
}
|
||||
const subscriber = event.getSender();
|
||||
const received: MessageCreateDto = {
|
||||
mid: messageId,
|
||||
sender: subscriber.id,
|
||||
message: event.getMessage(),
|
||||
delivery: true,
|
||||
read: true,
|
||||
};
|
||||
this.logger.debug('Logging message', received);
|
||||
try {
|
||||
const msg = await this.messageService.create(received);
|
||||
const populatedMsg = await this.messageService.findOneAndPopulate(msg.id);
|
||||
this.websocketGateway.broadcastMessageReceived(populatedMsg, subscriber);
|
||||
this.eventEmitter.emit('hook:stats:entry', 'incoming', 'Incoming');
|
||||
this.eventEmitter.emit(
|
||||
'hook:stats:entry',
|
||||
'all_messages',
|
||||
'All Messages',
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error('Unable to log received message.', err, event);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks messages as delivered and broadcast it to the websocket "Message" room
|
||||
*
|
||||
* @param event - The received event
|
||||
*/
|
||||
@OnEvent('hook:chatbot:delivery')
|
||||
async handleMessageDelivery(event: EventWrapper<any, any>) {
|
||||
if (config.chatbot.messages.track_delivery) {
|
||||
const subscriber = event.getSender();
|
||||
const deliveredMessages = event.getDeliveredMessages();
|
||||
try {
|
||||
await this.messageService.updateMany(
|
||||
{ mid: { $in: deliveredMessages } },
|
||||
{ delivery: true },
|
||||
);
|
||||
this.websocketGateway.broadcastMessageDelivered(
|
||||
deliveredMessages,
|
||||
subscriber,
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error('Unable to mark message as delivered.', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark messages as read and broadcast to websocket "Message" room
|
||||
*
|
||||
* @param event - The received event
|
||||
*/
|
||||
@OnEvent('hook:chatbot:read')
|
||||
async handleMessageRead(event: EventWrapper<any, any>) {
|
||||
if (config.chatbot.messages.track_read) {
|
||||
const subscriber = event.getSender();
|
||||
const watermark = new Date(event.getWatermark());
|
||||
const start = new Date(watermark.getTime() - 24 * 3600 * 1000);
|
||||
|
||||
try {
|
||||
await this.messageService.updateMany(
|
||||
{
|
||||
// @ts-expect-error Invesstigate why this is causing a ts error
|
||||
recipient: subscriber.id,
|
||||
createdAt: { $lte: watermark, $gte: start },
|
||||
},
|
||||
{
|
||||
delivery: true,
|
||||
read: true,
|
||||
},
|
||||
);
|
||||
this.websocketGateway.broadcastMessageRead(
|
||||
watermark.getTime(),
|
||||
subscriber,
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error('Unable to mark message as read.', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle echoing messages
|
||||
*
|
||||
* @param event - The received event
|
||||
*/
|
||||
@OnEvent('hook:chatbot:echo')
|
||||
async handleEchoMessage(event: EventWrapper<any, any>) {
|
||||
this.logger.verbose('Message echo received', event._adapter._raw);
|
||||
const foreignId = event.getRecipientForeignId();
|
||||
|
||||
if (foreignId) {
|
||||
try {
|
||||
const recipient = await this.subscriberService.findOne({
|
||||
foreign_id: foreignId,
|
||||
});
|
||||
|
||||
if (!recipient) {
|
||||
throw new Error(`Subscriber with foreign ID ${foreignId} not found`);
|
||||
}
|
||||
|
||||
const sentMessage: MessageCreateDto = {
|
||||
mid: event.getId(),
|
||||
recipient: recipient.id,
|
||||
message: event.getMessage(),
|
||||
delivery: false,
|
||||
read: false,
|
||||
};
|
||||
|
||||
this.eventEmitter.emit('hook:chatbot:sent', sentMessage);
|
||||
this.eventEmitter.emit('hook:stats:entry', 'echo', 'Echo');
|
||||
} catch (err) {
|
||||
this.logger.error('Unable to log echo message', err, event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle new incoming messages
|
||||
*
|
||||
* @param event - The received event
|
||||
*/
|
||||
@OnEvent('hook:chatbot:message')
|
||||
async handleNewMessage(event: EventWrapper<any, any>) {
|
||||
this.logger.debug('New message received', event);
|
||||
|
||||
const foreignId = event.getSenderForeignId();
|
||||
const handler = event.getHandler();
|
||||
|
||||
try {
|
||||
let subscriber = await this.subscriberService.findOne({
|
||||
foreign_id: foreignId,
|
||||
});
|
||||
|
||||
if (!subscriber) {
|
||||
const subscriberData = await handler.getUserData(event);
|
||||
this.eventEmitter.emit('hook:stats:entry', 'new_users', 'New users');
|
||||
subscriberData.channel = {
|
||||
...event.getChannelData(),
|
||||
name: handler.getChannel(),
|
||||
};
|
||||
subscriber = await this.subscriberService.create(subscriberData);
|
||||
} else {
|
||||
// Already existing user profile
|
||||
// Exec lastvisit hook
|
||||
this.eventEmitter.emit('hook:user:lastvisit', subscriber);
|
||||
}
|
||||
|
||||
this.websocketGateway.broadcastSubscriberUpdate(subscriber);
|
||||
|
||||
event.setSender(subscriber);
|
||||
|
||||
// Trigger message received event
|
||||
this.eventEmitter.emit('hook:chatbot:received', event);
|
||||
|
||||
if (subscriber.assignedTo) {
|
||||
this.logger.debug('Conversation taken over', subscriber.assignedTo);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.getText() && !event.getNLP()) {
|
||||
const nlpAdapter = this.nlpService.getNLP();
|
||||
try {
|
||||
const nlp = await nlpAdapter.parse(event.getText());
|
||||
event.setNLP(nlp);
|
||||
} catch (err) {
|
||||
this.logger.error('Unable to perform NLP parse', err);
|
||||
}
|
||||
}
|
||||
|
||||
this.botService.handleMessageEvent(event);
|
||||
} catch (err) {
|
||||
this.logger.error('Error handling new message', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle new subscriber and send notification the websocket
|
||||
*
|
||||
* @param subscriber - The end user (subscriber)
|
||||
*/
|
||||
@OnEvent('hook:chatbot:subscriber:create')
|
||||
onSubscriberCreate(subscriber: Subscriber) {
|
||||
this.websocketGateway.broadcastSubscriberNew(subscriber);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle updated subscriber and send notification the websocket
|
||||
*
|
||||
* @param subscriber - The end user (subscriber)
|
||||
*/
|
||||
@OnEvent('hook:chatbot:subscriber:update:after')
|
||||
onSubscriberUpdate(subscriber: Subscriber) {
|
||||
this.websocketGateway.broadcastSubscriberUpdate(subscriber);
|
||||
}
|
||||
}
|
||||
22
api/src/chat/services/context-var.service.ts
Normal file
22
api/src/chat/services/context-var.service.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Copyright © 2024 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).
|
||||
* 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 { BaseService } from '@/utils/generics/base-service';
|
||||
|
||||
import { ContextVarRepository } from '../repositories/context-var.repository';
|
||||
import { ContextVar } from '../schemas/context-var.schema';
|
||||
|
||||
@Injectable()
|
||||
export class ContextVarService extends BaseService<ContextVar> {
|
||||
constructor(readonly repository: ContextVarRepository) {
|
||||
super(repository);
|
||||
}
|
||||
}
|
||||
183
api/src/chat/services/conversation.service.ts
Normal file
183
api/src/chat/services/conversation.service.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
/*
|
||||
* Copyright © 2024 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).
|
||||
* 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 { FilterQuery } from 'mongoose';
|
||||
|
||||
import EventWrapper from '@/channel/lib/EventWrapper';
|
||||
import { LoggerService } from '@/logger/logger.service';
|
||||
import { BaseService } from '@/utils/generics/base-service';
|
||||
|
||||
import { VIEW_MORE_PAYLOAD } from '../helpers/constants';
|
||||
import { ConversationRepository } from '../repositories/conversation.repository';
|
||||
import { Block, BlockFull } from '../schemas/block.schema';
|
||||
import { Conversation, ConversationFull } from '../schemas/conversation.schema';
|
||||
import { OutgoingMessageFormat } from '../schemas/types/message';
|
||||
import { Payload } from '../schemas/types/quick-reply';
|
||||
|
||||
@Injectable()
|
||||
export class ConversationService extends BaseService<Conversation> {
|
||||
constructor(
|
||||
readonly repository: ConversationRepository,
|
||||
private readonly logger: LoggerService,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds and retrieves a single conversation entity based on the provided criteria,
|
||||
* while populating the necessary related data.
|
||||
*
|
||||
* @param criteria - The search criteria to find a conversation, which can be a string
|
||||
* representing a unique identifier or a filter query object that specifies the conditions
|
||||
* for the search.
|
||||
*
|
||||
* @returns A Promise that resolves with the found conversation, populated with any related data,
|
||||
* or `null` if no conversation matches the criteria.
|
||||
*/
|
||||
async findOneAndPopulate(criteria: string | FilterQuery<Conversation>) {
|
||||
return await this.repository.findOneAndPopulate(criteria);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the conversation as inactive.
|
||||
*
|
||||
* @param convo - The conversation
|
||||
*/
|
||||
async end(convo: Conversation | ConversationFull) {
|
||||
return await this.repository.end(convo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the actual conversation context and returns the updated conversation
|
||||
*
|
||||
* @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
|
||||
*
|
||||
* @returns The updated conversation
|
||||
*/
|
||||
async storeContextData(
|
||||
convo: Conversation | ConversationFull,
|
||||
next: Block | BlockFull,
|
||||
event: EventWrapper<any, any>,
|
||||
captureVars: boolean = false,
|
||||
) {
|
||||
const msgType = event.getMessageType();
|
||||
// Capture channel specific context data
|
||||
convo.context.channel = event.getHandler().getChannel();
|
||||
convo.context.text = event.getText();
|
||||
convo.context.payload = event.getPayload();
|
||||
convo.context.nlp = event.getNLP();
|
||||
convo.context.vars = convo.context.vars || {};
|
||||
|
||||
// Capture user entry in context vars
|
||||
if (captureVars && next.capture_vars && next.capture_vars.length > 0) {
|
||||
next.capture_vars.forEach((capture) => {
|
||||
let contextValue: string | Payload;
|
||||
|
||||
const nlp = event.getNLP();
|
||||
|
||||
if (nlp && nlp.entities && nlp.entities.length) {
|
||||
const nlpIndex = nlp.entities
|
||||
.map((n) => {
|
||||
return n.entity;
|
||||
})
|
||||
.indexOf(capture.entity.toString());
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
if (capture.entity === -1) {
|
||||
// Capture the whole message
|
||||
contextValue =
|
||||
['message', 'quick_reply'].indexOf(msgType) !== -1
|
||||
? event.getText()
|
||||
: event.getPayload();
|
||||
} else if (capture.entity === -2) {
|
||||
// Capture the postback payload (button click)
|
||||
contextValue = event.getPayload();
|
||||
}
|
||||
contextValue =
|
||||
typeof contextValue === 'string' ? contextValue.trim() : contextValue;
|
||||
|
||||
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;
|
||||
convo.context.user.first_name = profile.first_name || '';
|
||||
convo.context.user.last_name = profile.last_name || '';
|
||||
if (profile.language) {
|
||||
convo.context.user.language = profile.language;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle attachments (location, ...)
|
||||
if (msgType === 'location') {
|
||||
const coordinates = event.getMessage().coordinates;
|
||||
convo.context.user_location = { lat: 0, lon: 0 };
|
||||
convo.context.user_location.lat = parseFloat(coordinates.lat);
|
||||
convo.context.user_location.lon = parseFloat(coordinates.lon);
|
||||
} else if (msgType === 'attachments') {
|
||||
// @TODO : deprecated in favor of geolocation msgType
|
||||
const attachments = event.getAttachments();
|
||||
// @ts-expect-error deprecated
|
||||
if (attachments.length === 1 && attachments[0].type === 'location') {
|
||||
// @ts-expect-error deprecated
|
||||
const coord = attachments[0].payload.coordinates;
|
||||
convo.context.user_location = { lat: 0, lon: 0 };
|
||||
convo.context.user_location.lat = parseFloat(coord.lat);
|
||||
convo.context.user_location.lon = parseFloat(coord.long);
|
||||
}
|
||||
}
|
||||
|
||||
// Deal with load more in the case of a list display
|
||||
if (
|
||||
next.options.content &&
|
||||
(next.options.content.display === OutgoingMessageFormat.list ||
|
||||
next.options.content.display === OutgoingMessageFormat.carousel)
|
||||
) {
|
||||
if (event.getPayload() === VIEW_MORE_PAYLOAD) {
|
||||
convo.context.skip[next.id] += next.options.content.limit;
|
||||
} else {
|
||||
convo.context.skip = convo.context.skip ? convo.context.skip : {};
|
||||
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, {
|
||||
context: convo.context,
|
||||
});
|
||||
if (!updatedConversation) {
|
||||
throw new Error(
|
||||
'Conversation Model : No conversation has been updated',
|
||||
);
|
||||
}
|
||||
return updatedConversation;
|
||||
} catch (err) {
|
||||
this.logger.error('Conversation Model : Unable to store context', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
113
api/src/chat/services/label.service.spec.ts
Normal file
113
api/src/chat/services/label.service.spec.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* Copyright © 2024 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).
|
||||
* 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 { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { MongooseModule } from '@nestjs/mongoose';
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import { AttachmentRepository } from '@/attachment/repositories/attachment.repository';
|
||||
import { AttachmentModel } from '@/attachment/schemas/attachment.schema';
|
||||
import { AttachmentService } from '@/attachment/services/attachment.service';
|
||||
import { LoggerService } from '@/logger/logger.service';
|
||||
import {
|
||||
installLabelFixtures,
|
||||
labelFixtures,
|
||||
} from '@/utils/test/fixtures/label';
|
||||
import { getPageQuery } from '@/utils/test/pagination';
|
||||
import { sortRowsBy } from '@/utils/test/sort';
|
||||
import {
|
||||
closeInMongodConnection,
|
||||
rootMongooseTestModule,
|
||||
} from '@/utils/test/test';
|
||||
|
||||
import { SubscriberRepository } from './../repositories/subscriber.repository';
|
||||
import { LabelService } from './label.service';
|
||||
import { SubscriberService } from './subscriber.service';
|
||||
import { LabelRepository } from '../repositories/label.repository';
|
||||
import { LabelModel, Label, LabelFull } from '../schemas/label.schema';
|
||||
import { SubscriberModel, Subscriber } from '../schemas/subscriber.schema';
|
||||
|
||||
describe('LabelService', () => {
|
||||
let labelRepository: LabelRepository;
|
||||
let labelService: LabelService;
|
||||
let subscriberRepository: SubscriberRepository;
|
||||
let allSubscribers: Subscriber[];
|
||||
let allLabels: Label[];
|
||||
let labelsWithUsers: LabelFull[];
|
||||
|
||||
beforeAll(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
imports: [
|
||||
rootMongooseTestModule(installLabelFixtures),
|
||||
MongooseModule.forFeature([
|
||||
LabelModel,
|
||||
SubscriberModel,
|
||||
AttachmentModel,
|
||||
]),
|
||||
],
|
||||
providers: [
|
||||
LoggerService,
|
||||
LabelService,
|
||||
LabelRepository,
|
||||
SubscriberService,
|
||||
AttachmentService,
|
||||
AttachmentRepository,
|
||||
SubscriberRepository,
|
||||
EventEmitter2,
|
||||
],
|
||||
}).compile();
|
||||
labelService = module.get<LabelService>(LabelService);
|
||||
labelRepository = module.get<LabelRepository>(LabelRepository);
|
||||
subscriberRepository =
|
||||
module.get<SubscriberRepository>(SubscriberRepository);
|
||||
allSubscribers = await subscriberRepository.findAll();
|
||||
allLabels = await labelRepository.findAll();
|
||||
labelsWithUsers = allLabels.map((label) => ({
|
||||
...label,
|
||||
users: allSubscribers,
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(jest.clearAllMocks);
|
||||
afterAll(closeInMongodConnection);
|
||||
|
||||
describe('findAllAndPopulate', () => {
|
||||
it('should find all labels, and foreach label populate its corresponding users', async () => {
|
||||
jest.spyOn(labelRepository, 'findAllAndPopulate');
|
||||
const result = await labelService.findAllAndPopulate();
|
||||
|
||||
expect(labelRepository.findAllAndPopulate).toHaveBeenCalled();
|
||||
expect(result).toEqualPayload(labelsWithUsers);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findPageAndPopulate', () => {
|
||||
const pageQuery = getPageQuery<Label>();
|
||||
it('should find labels, and foreach label populate its corresponding users', async () => {
|
||||
jest.spyOn(labelRepository, 'findPageAndPopulate');
|
||||
const result = await labelService.findPageAndPopulate({}, pageQuery);
|
||||
|
||||
expect(labelRepository.findPageAndPopulate).toHaveBeenCalled();
|
||||
expect(result).toEqualPayload(labelsWithUsers.sort(sortRowsBy));
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOneAndPopulate', () => {
|
||||
it('should find one label by id, and populate its corresponding users', async () => {
|
||||
jest.spyOn(labelRepository, 'findOneAndPopulate');
|
||||
const label = await labelRepository.findOne({ name: 'TEST_TITLE_1' });
|
||||
const result = await labelService.findOneAndPopulate(label.id);
|
||||
|
||||
expect(result).toEqualPayload({
|
||||
...labelFixtures.find(({ name }) => name === label.name),
|
||||
users: allSubscribers,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
59
api/src/chat/services/label.service.ts
Normal file
59
api/src/chat/services/label.service.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* Copyright © 2024 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).
|
||||
* 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 { TFilterQuery } from 'mongoose';
|
||||
|
||||
import { BaseService } from '@/utils/generics/base-service';
|
||||
import { PageQueryDto } from '@/utils/pagination/pagination-query.dto';
|
||||
|
||||
import { LabelRepository } from '../repositories/label.repository';
|
||||
import { Label } from '../schemas/label.schema';
|
||||
|
||||
@Injectable()
|
||||
export class LabelService extends BaseService<Label> {
|
||||
constructor(readonly repository: LabelRepository) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds all labels and populates related data.
|
||||
*
|
||||
* @returns A promise that resolves with an array of labels with populated related data.
|
||||
*/
|
||||
async findAllAndPopulate() {
|
||||
return await this.repository.findAllAndPopulate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a page of labels based on filters and page query, and populates related data.
|
||||
*
|
||||
* @param filters The filters to apply when querying the labels.
|
||||
* @param pageQuery The pagination and sorting options.
|
||||
*
|
||||
* @returns A promise that resolves with a paginated list of labels with populated related data.
|
||||
*/
|
||||
async findPageAndPopulate(
|
||||
filters: TFilterQuery<Label>,
|
||||
pageQuery: PageQueryDto<Label>,
|
||||
) {
|
||||
return await this.repository.findPageAndPopulate(filters, pageQuery);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a label by ID and populates related data.
|
||||
*
|
||||
* @param id The ID of the label to find.
|
||||
*
|
||||
* @returns A promise that resolves with the found label with populated related data or null if not found.
|
||||
*/
|
||||
async findOneAndPopulate(id: string) {
|
||||
return await this.repository.findOneAndPopulate(id);
|
||||
}
|
||||
}
|
||||
190
api/src/chat/services/message.service.spec.ts
Normal file
190
api/src/chat/services/message.service.spec.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
/*
|
||||
* Copyright © 2024 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).
|
||||
* 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 { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { MongooseModule } from '@nestjs/mongoose';
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import { AttachmentRepository } from '@/attachment/repositories/attachment.repository';
|
||||
import { AttachmentModel } from '@/attachment/schemas/attachment.schema';
|
||||
import { AttachmentService } from '@/attachment/services/attachment.service';
|
||||
import { LoggerService } from '@/logger/logger.service';
|
||||
import { RoleRepository } from '@/user/repositories/role.repository';
|
||||
import { UserRepository } from '@/user/repositories/user.repository';
|
||||
import { PermissionModel } from '@/user/schemas/permission.schema';
|
||||
import { RoleModel } from '@/user/schemas/role.schema';
|
||||
import { User, UserModel } from '@/user/schemas/user.schema';
|
||||
import { RoleService } from '@/user/services/role.service';
|
||||
import { UserService } from '@/user/services/user.service';
|
||||
import {
|
||||
installMessageFixtures,
|
||||
messageFixtures,
|
||||
} from '@/utils/test/fixtures/message';
|
||||
import { getPageQuery } from '@/utils/test/pagination';
|
||||
import { sortRowsBy } from '@/utils/test/sort';
|
||||
import {
|
||||
closeInMongodConnection,
|
||||
rootMongooseTestModule,
|
||||
} from '@/utils/test/test';
|
||||
|
||||
import { SubscriberRepository } from './../repositories/subscriber.repository';
|
||||
import { MessageService } from './message.service';
|
||||
import { SubscriberService } from './subscriber.service';
|
||||
import { MessageRepository } from '../repositories/message.repository';
|
||||
import { Message, MessageModel } from '../schemas/message.schema';
|
||||
import { Subscriber, SubscriberModel } from '../schemas/subscriber.schema';
|
||||
|
||||
describe('MessageService', () => {
|
||||
let messageRepository: MessageRepository;
|
||||
let messageService: MessageService;
|
||||
let subscriberRepository: SubscriberRepository;
|
||||
let userRepository: UserRepository;
|
||||
let allMessages: Message[];
|
||||
let allSubscribers: Subscriber[];
|
||||
let allUsers: User[];
|
||||
let message: Message;
|
||||
let sender: Subscriber;
|
||||
let recipient: Subscriber;
|
||||
let messagesWithSenderAndRecipient: Message[];
|
||||
let user: User;
|
||||
|
||||
beforeAll(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
imports: [
|
||||
rootMongooseTestModule(installMessageFixtures),
|
||||
MongooseModule.forFeature([
|
||||
UserModel,
|
||||
RoleModel,
|
||||
PermissionModel,
|
||||
SubscriberModel,
|
||||
MessageModel,
|
||||
AttachmentModel,
|
||||
]),
|
||||
],
|
||||
providers: [
|
||||
LoggerService,
|
||||
AttachmentService,
|
||||
AttachmentRepository,
|
||||
UserService,
|
||||
UserRepository,
|
||||
RoleService,
|
||||
RoleRepository,
|
||||
SubscriberService,
|
||||
SubscriberRepository,
|
||||
MessageService,
|
||||
MessageRepository,
|
||||
EventEmitter2,
|
||||
],
|
||||
}).compile();
|
||||
messageService = module.get<MessageService>(MessageService);
|
||||
messageRepository = module.get<MessageRepository>(MessageRepository);
|
||||
subscriberRepository =
|
||||
module.get<SubscriberRepository>(SubscriberRepository);
|
||||
userRepository = module.get<UserRepository>(UserRepository);
|
||||
allSubscribers = await subscriberRepository.findAll();
|
||||
allUsers = await userRepository.findAll();
|
||||
allMessages = await messageRepository.findAll();
|
||||
message = await messageRepository.findOne({ mid: 'mid-1' });
|
||||
sender = await subscriberRepository.findOne(message['sender']);
|
||||
recipient = await subscriberRepository.findOne(message['recipient']);
|
||||
user = await userRepository.findOne(message['sentBy']);
|
||||
messagesWithSenderAndRecipient = allMessages.map((message) => ({
|
||||
...message,
|
||||
sender: allSubscribers.find(({ id }) => id === message['sender']).id,
|
||||
recipient: allSubscribers.find(({ id }) => id === message['recipient'])
|
||||
.id,
|
||||
sentBy: allUsers.find(({ id }) => id === message['sentBy']).id,
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(jest.clearAllMocks);
|
||||
afterAll(closeInMongodConnection);
|
||||
|
||||
describe('findOneAndPopulate', () => {
|
||||
it('should find message by id, and populate its corresponding sender and recipient', async () => {
|
||||
jest.spyOn(messageRepository, 'findOneAndPopulate');
|
||||
const result = await messageService.findOneAndPopulate(message.id);
|
||||
|
||||
expect(messageRepository.findOneAndPopulate).toHaveBeenCalledWith(
|
||||
message.id,
|
||||
);
|
||||
expect(result).toEqualPayload({
|
||||
...messageFixtures.find(({ mid }) => mid === message.mid),
|
||||
sender,
|
||||
recipient,
|
||||
sentBy: user.id,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('findPageAndPopulate', () => {
|
||||
const pageQuery = getPageQuery<Message>();
|
||||
it('should find messages, and foreach message populate the corresponding sender and recipient', async () => {
|
||||
jest.spyOn(messageRepository, 'findPageAndPopulate');
|
||||
const result = await messageService.findPageAndPopulate({}, pageQuery);
|
||||
const messagesWithSenderAndRecipient = allMessages.map((message) => ({
|
||||
...message,
|
||||
sender: allSubscribers.find(({ id }) => id === message['sender']),
|
||||
recipient: allSubscribers.find(({ id }) => id === message['recipient']),
|
||||
sentBy: allUsers.find(({ id }) => id === message['sentBy']).id,
|
||||
}));
|
||||
|
||||
expect(messageRepository.findPageAndPopulate).toHaveBeenCalledWith(
|
||||
{},
|
||||
pageQuery,
|
||||
);
|
||||
expect(result).toEqualPayload(messagesWithSenderAndRecipient);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findHistoryUntilDate', () => {
|
||||
it('should return history until given date', async () => {
|
||||
const until: Date = new Date(
|
||||
new Date().setMonth(new Date().getMonth() + 1),
|
||||
);
|
||||
const result = await messageService.findHistoryUntilDate(
|
||||
sender,
|
||||
until,
|
||||
30,
|
||||
);
|
||||
const historyMessages = messagesWithSenderAndRecipient.filter(
|
||||
(message) => message.createdAt <= until,
|
||||
);
|
||||
|
||||
expect(result).toEqualPayload(historyMessages);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findHistorySinceDate', () => {
|
||||
it('should return history since given date', async () => {
|
||||
const since: Date = new Date();
|
||||
const result = await messageService.findHistorySinceDate(
|
||||
sender,
|
||||
since,
|
||||
30,
|
||||
);
|
||||
const messagesWithSenderAndRecipient = allMessages.map((message) => ({
|
||||
...message,
|
||||
sender: allSubscribers.find(({ id }) => id === message['sender']).id,
|
||||
recipient: allSubscribers.find(({ id }) => id === message['recipient'])
|
||||
.id,
|
||||
sentBy: allUsers.find(({ id }) => id === message['sentBy']).id,
|
||||
}));
|
||||
const historyMessages = messagesWithSenderAndRecipient.filter(
|
||||
(message) => message.createdAt > since,
|
||||
);
|
||||
|
||||
expect(result).toEqual(
|
||||
historyMessages.sort((message1, message2) =>
|
||||
sortRowsBy(message1, message2, 'createdAt', 'asc'),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
145
api/src/chat/services/message.service.ts
Normal file
145
api/src/chat/services/message.service.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
/*
|
||||
* Copyright © 2024 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).
|
||||
* 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,
|
||||
InternalServerErrorException,
|
||||
Optional,
|
||||
} from '@nestjs/common';
|
||||
import { TFilterQuery } from 'mongoose';
|
||||
|
||||
import { LoggerService } from '@/logger/logger.service';
|
||||
import { BaseService } from '@/utils/generics/base-service';
|
||||
import { PageQueryDto } from '@/utils/pagination/pagination-query.dto';
|
||||
import {
|
||||
SocketGet,
|
||||
SocketPost,
|
||||
} from '@/websocket/decorators/socket-method.decorator';
|
||||
import { SocketReq } from '@/websocket/decorators/socket-req.decorator';
|
||||
import { SocketRes } from '@/websocket/decorators/socket-res.decorator';
|
||||
import { Room } from '@/websocket/types';
|
||||
import { SocketRequest } from '@/websocket/utils/socket-request';
|
||||
import { SocketResponse } from '@/websocket/utils/socket-response';
|
||||
import { WebsocketGateway } from '@/websocket/websocket.gateway';
|
||||
|
||||
import { MessageRepository } from '../repositories/message.repository';
|
||||
import { Subscriber } from '../schemas/subscriber.schema';
|
||||
import { AnyMessage } from '../schemas/types/message';
|
||||
|
||||
@Injectable()
|
||||
export class MessageService extends BaseService<AnyMessage> {
|
||||
private readonly logger: LoggerService;
|
||||
|
||||
private readonly gateway: WebsocketGateway;
|
||||
|
||||
constructor(
|
||||
private readonly messageRepository: MessageRepository,
|
||||
@Optional() logger?: LoggerService,
|
||||
@Optional() gateway?: WebsocketGateway,
|
||||
) {
|
||||
super(messageRepository);
|
||||
this.logger = logger;
|
||||
this.gateway = gateway;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes the socket to the message room
|
||||
*
|
||||
* @param req - The socket request object
|
||||
* @param res - The socket response object
|
||||
*/
|
||||
@SocketGet('/message/subscribe/')
|
||||
@SocketPost('/message/subscribe/')
|
||||
subscribe(@SocketReq() req: SocketRequest, @SocketRes() res: SocketResponse) {
|
||||
try {
|
||||
this.gateway.io.socketsJoin(Room.MESSAGE);
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
});
|
||||
} catch (e) {
|
||||
this.logger.error(
|
||||
'MessageController subscribe : Websocket subscription',
|
||||
e,
|
||||
);
|
||||
throw new InternalServerErrorException(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a paginated list of messages based on the provided filters
|
||||
* and page query, and populates the results with additional data.
|
||||
*
|
||||
* @param filters - The query filters to apply for message retrieval.
|
||||
* @param pageQuery - The page query containing pagination information.
|
||||
*
|
||||
* @returns A paginated list of populated messages.
|
||||
*/
|
||||
async findPageAndPopulate(
|
||||
filters: TFilterQuery<AnyMessage>,
|
||||
pageQuery: PageQueryDto<AnyMessage>,
|
||||
) {
|
||||
return await this.messageRepository.findPageAndPopulate(filters, pageQuery);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a single message by its identifier and populates it with
|
||||
* additional data.
|
||||
*
|
||||
* @param id - The identifier of the message to retrieve.
|
||||
*
|
||||
* @returns A populated message object.
|
||||
*/
|
||||
async findOneAndPopulate(id: string) {
|
||||
return await this.messageRepository.findOneAndPopulate(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the message history for a given subscriber up until a specific
|
||||
* date, with an optional limit on the number of messages to return.
|
||||
*
|
||||
* @param subscriber - The subscriber whose message history is being retrieved.
|
||||
* @param until - The date until which to retrieve messages (defaults to the current date).
|
||||
* @param limit - The maximum number of messages to return (defaults to 30).
|
||||
*
|
||||
* @returns The message history until the specified date.
|
||||
*/
|
||||
async findHistoryUntilDate(
|
||||
subscriber: Subscriber,
|
||||
until = new Date(),
|
||||
limit: number = 30,
|
||||
) {
|
||||
return await this.messageRepository.findHistoryUntilDate(
|
||||
subscriber,
|
||||
until,
|
||||
limit,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the message history for a given subscriber since a specific
|
||||
* date, with an optional limit on the number of messages to return.
|
||||
*
|
||||
* @param subscriber - The subscriber whose message history is being retrieved.
|
||||
* @param since - The date since which to retrieve messages (defaults to the current date).
|
||||
* @param limit - The maximum number of messages to return (defaults to 30).
|
||||
*
|
||||
* @returns The message history since the specified date.
|
||||
*/
|
||||
async findHistorySinceDate(
|
||||
subscriber: Subscriber,
|
||||
since = new Date(),
|
||||
limit: number = 30,
|
||||
) {
|
||||
return await this.messageRepository.findHistorySinceDate(
|
||||
subscriber,
|
||||
since,
|
||||
limit,
|
||||
);
|
||||
}
|
||||
}
|
||||
147
api/src/chat/services/subscriber.service.spec.ts
Normal file
147
api/src/chat/services/subscriber.service.spec.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* Copyright © 2024 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).
|
||||
* 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 { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { MongooseModule } from '@nestjs/mongoose';
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import { AttachmentRepository } from '@/attachment/repositories/attachment.repository';
|
||||
import { AttachmentModel } from '@/attachment/schemas/attachment.schema';
|
||||
import { AttachmentService } from '@/attachment/services/attachment.service';
|
||||
import { LoggerService } from '@/logger/logger.service';
|
||||
import { RoleRepository } from '@/user/repositories/role.repository';
|
||||
import { UserRepository } from '@/user/repositories/user.repository';
|
||||
import { PermissionModel } from '@/user/schemas/permission.schema';
|
||||
import { RoleModel } from '@/user/schemas/role.schema';
|
||||
import { User, UserModel } from '@/user/schemas/user.schema';
|
||||
import { RoleService } from '@/user/services/role.service';
|
||||
import { UserService } from '@/user/services/user.service';
|
||||
import { installSubscriberFixtures } from '@/utils/test/fixtures/subscriber';
|
||||
import { getPageQuery } from '@/utils/test/pagination';
|
||||
import { sortRowsBy } from '@/utils/test/sort';
|
||||
import {
|
||||
closeInMongodConnection,
|
||||
rootMongooseTestModule,
|
||||
} from '@/utils/test/test';
|
||||
|
||||
import { LabelService } from './label.service';
|
||||
import { SubscriberService } from './subscriber.service';
|
||||
import { LabelRepository } from '../repositories/label.repository';
|
||||
import { SubscriberRepository } from '../repositories/subscriber.repository';
|
||||
import { Label, LabelModel } from '../schemas/label.schema';
|
||||
import { Subscriber, SubscriberModel } from '../schemas/subscriber.schema';
|
||||
|
||||
describe('SubscriberService', () => {
|
||||
let subscriberRepository: SubscriberRepository;
|
||||
let labelRepository: LabelRepository;
|
||||
let userRepository: UserRepository;
|
||||
let subscriberService: SubscriberService;
|
||||
let allSubscribers: Subscriber[];
|
||||
let allLabels: Label[];
|
||||
let allUsers: User[];
|
||||
|
||||
beforeAll(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
imports: [
|
||||
rootMongooseTestModule(installSubscriberFixtures),
|
||||
MongooseModule.forFeature([
|
||||
SubscriberModel,
|
||||
LabelModel,
|
||||
UserModel,
|
||||
RoleModel,
|
||||
PermissionModel,
|
||||
AttachmentModel,
|
||||
]),
|
||||
],
|
||||
providers: [
|
||||
SubscriberService,
|
||||
SubscriberRepository,
|
||||
LabelService,
|
||||
LabelRepository,
|
||||
UserService,
|
||||
UserRepository,
|
||||
RoleService,
|
||||
RoleRepository,
|
||||
LoggerService,
|
||||
EventEmitter2,
|
||||
AttachmentService,
|
||||
AttachmentRepository,
|
||||
],
|
||||
}).compile();
|
||||
labelRepository = module.get<LabelRepository>(LabelRepository);
|
||||
userRepository = module.get<UserRepository>(UserRepository);
|
||||
subscriberService = module.get<SubscriberService>(SubscriberService);
|
||||
subscriberRepository =
|
||||
module.get<SubscriberRepository>(SubscriberRepository);
|
||||
allSubscribers = await subscriberRepository.findAll();
|
||||
allLabels = await labelRepository.findAll();
|
||||
allUsers = await userRepository.findAll();
|
||||
});
|
||||
|
||||
afterEach(jest.clearAllMocks);
|
||||
afterAll(closeInMongodConnection);
|
||||
|
||||
describe('findOneAndPopulate', () => {
|
||||
it('should find subscribers, and foreach subscriber populate its corresponding labels', async () => {
|
||||
jest.spyOn(subscriberService, 'findOneAndPopulate');
|
||||
const subscriber = await subscriberRepository.findOne({
|
||||
first_name: 'Jhon',
|
||||
});
|
||||
const result = await subscriberService.findOneAndPopulate(subscriber.id);
|
||||
|
||||
expect(subscriberService.findOneAndPopulate).toHaveBeenCalledWith(
|
||||
subscriber.id,
|
||||
);
|
||||
expect(result).toEqualPayload({
|
||||
...subscriber,
|
||||
labels: allLabels.filter((label) =>
|
||||
subscriber.labels.includes(label.id),
|
||||
),
|
||||
assignedTo: allUsers.find(({ id }) => subscriber.assignedTo === id),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('findPageAndPopulate', () => {
|
||||
const pageQuery = getPageQuery<Subscriber>();
|
||||
it('should find subscribers, and foreach subscriber populate its corresponding labels', async () => {
|
||||
jest.spyOn(subscriberRepository, 'findPageAndPopulate');
|
||||
const result = await subscriberService.findPageAndPopulate({}, pageQuery);
|
||||
const subscribersWithLabels = allSubscribers.map((subscriber) => ({
|
||||
...subscriber,
|
||||
labels: allLabels.filter((label) =>
|
||||
subscriber.labels.includes(label.id),
|
||||
),
|
||||
assignedTo: allUsers.find(({ id }) => subscriber.assignedTo === id),
|
||||
}));
|
||||
|
||||
expect(subscriberRepository.findPageAndPopulate).toHaveBeenCalled();
|
||||
expect(result).toEqualPayload(subscribersWithLabels.sort(sortRowsBy));
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOneByForeignId', () => {
|
||||
it('should find one subscriber by foreign id', async () => {
|
||||
jest.spyOn(subscriberRepository, 'findOneByForeignId');
|
||||
const result =
|
||||
await subscriberService.findOneByForeignId('foreign-id-dimelo');
|
||||
const subscriber = allSubscribers.find(
|
||||
({ foreign_id }) => foreign_id === 'foreign-id-dimelo',
|
||||
);
|
||||
|
||||
expect(subscriberRepository.findOneByForeignId).toHaveBeenCalled();
|
||||
expect(result).toEqualPayload({
|
||||
...subscriber,
|
||||
labels: allLabels
|
||||
.filter((label) => subscriber.labels.includes(label.id))
|
||||
.map((label) => label.id),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
253
api/src/chat/services/subscriber.service.ts
Normal file
253
api/src/chat/services/subscriber.service.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
/*
|
||||
* Copyright © 2024 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).
|
||||
* 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,
|
||||
InternalServerErrorException,
|
||||
NotFoundException,
|
||||
Optional,
|
||||
StreamableFile,
|
||||
} from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { TFilterQuery } from 'mongoose';
|
||||
|
||||
import { AttachmentService } from '@/attachment/services/attachment.service';
|
||||
import { config } from '@/config';
|
||||
import { LoggerService } from '@/logger/logger.service';
|
||||
import { BaseService } from '@/utils/generics/base-service';
|
||||
import { PageQueryDto } from '@/utils/pagination/pagination-query.dto';
|
||||
import {
|
||||
SocketGet,
|
||||
SocketPost,
|
||||
} from '@/websocket/decorators/socket-method.decorator';
|
||||
import { SocketReq } from '@/websocket/decorators/socket-req.decorator';
|
||||
import { SocketRes } from '@/websocket/decorators/socket-res.decorator';
|
||||
import { Room } from '@/websocket/types';
|
||||
import { SocketRequest } from '@/websocket/utils/socket-request';
|
||||
import { SocketResponse } from '@/websocket/utils/socket-response';
|
||||
import { WebsocketGateway } from '@/websocket/websocket.gateway';
|
||||
|
||||
import { SubscriberUpdateDto } from '../dto/subscriber.dto';
|
||||
import { SubscriberRepository } from '../repositories/subscriber.repository';
|
||||
import { Subscriber } from '../schemas/subscriber.schema';
|
||||
|
||||
@Injectable()
|
||||
export class SubscriberService extends BaseService<Subscriber> {
|
||||
private readonly gateway: WebsocketGateway;
|
||||
|
||||
constructor(
|
||||
readonly repository: SubscriberRepository,
|
||||
private readonly logger: LoggerService,
|
||||
protected attachmentService: AttachmentService,
|
||||
@Optional() gateway?: WebsocketGateway,
|
||||
) {
|
||||
super(repository);
|
||||
this.gateway = gateway;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internally subscribe web-sockets to user's event
|
||||
* For example : Notify chat if new user interacted with the chatbot
|
||||
*
|
||||
* @param req - The socket request object
|
||||
* @param res - The socket response object
|
||||
*/
|
||||
@SocketGet('/subscriber/subscribe/')
|
||||
@SocketPost('/subscriber/subscribe/')
|
||||
subscribe(@SocketReq() req: SocketRequest, @SocketRes() res: SocketResponse) {
|
||||
try {
|
||||
this.gateway.io.socketsJoin(Room.SUBSCRIBER);
|
||||
return res.json({
|
||||
success: true,
|
||||
subscribe: Room.SUBSCRIBER,
|
||||
});
|
||||
} catch (e) {
|
||||
this.logger.error(
|
||||
'SubscriberController subscribe : Websocket subscription',
|
||||
);
|
||||
throw new InternalServerErrorException(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a paginated list of subscribers based on the provided filters
|
||||
* and populates the result with related data.
|
||||
*
|
||||
* @param filters - The criteria used to filter the subscribers.
|
||||
* @param pageQuery - The pagination and sorting details.
|
||||
*
|
||||
* @returns A paginated list of subscribers with populated related data.
|
||||
*/
|
||||
async findPageAndPopulate(
|
||||
filters: TFilterQuery<Subscriber>,
|
||||
pageQuery: PageQueryDto<Subscriber>,
|
||||
) {
|
||||
return await this.repository.findPageAndPopulate(filters, pageQuery);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds and returns a single subscriber based on its ID or filters,
|
||||
* and populates the result with related data.
|
||||
*
|
||||
* @param id - The ID of the subscriber or a set of query filters.
|
||||
*
|
||||
* @returns The subscriber with populated related data.
|
||||
*/
|
||||
async findOneAndPopulate(id: string | TFilterQuery<Subscriber>) {
|
||||
return await this.repository.findOneAndPopulate(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds and returns a single subscriber based on a foreign ID.
|
||||
*
|
||||
* @param id - The foreign ID used to find the subscriber.
|
||||
*
|
||||
* @returns The subscriber matching the foreign ID.
|
||||
*/
|
||||
async findOneByForeignId(id: string) {
|
||||
return await this.repository.findOneByForeignId(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds and returns a single subscriber based on a foreign ID,
|
||||
* and populates the result with related data.
|
||||
*
|
||||
* @param id - The foreign ID used to find the subscriber.
|
||||
*
|
||||
* @returns The subscriber with populated related data.
|
||||
*/
|
||||
async findOneByForeignIdAndPopulate(id: string) {
|
||||
return await this.repository.findOneByForeignIdAndPopulate(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a subscriber's details based on a foreign ID.
|
||||
*
|
||||
* @param id - The foreign ID of the subscriber to update.
|
||||
* @param updates - The updates to apply to the subscriber.
|
||||
*
|
||||
* @returns The updated subscriber data.
|
||||
*/
|
||||
async updateOneByForeignId(id: string, updates: SubscriberUpdateDto) {
|
||||
return await this.repository.updateOneByForeignIdQuery(id, updates);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands back control or association of a subscriber based on a foreign ID.
|
||||
*
|
||||
* @param foreignId - The foreign ID of the subscriber.
|
||||
*
|
||||
* @returns The result of the hand-back operation.
|
||||
*/
|
||||
async handBackByForeignId(foreignId: string) {
|
||||
return await this.repository.handBackByForeignIdQuery(foreignId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands over control or association of a subscriber to another user
|
||||
* based on the foreign ID and the new user's ID.
|
||||
*
|
||||
* @param foreignId - The foreign ID of the subscriber.
|
||||
* @param userId - The ID of the user to whom control is handed over.
|
||||
*
|
||||
* @returns The result of the hand-over operation.
|
||||
*/
|
||||
async handOverByForeignId(foreignId: string, userId: string) {
|
||||
return await this.repository.handOverByForeignIdQuery(foreignId, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the profile picture of a subscriber based on the foreign ID.
|
||||
*
|
||||
* @param foreign_id - The foreign ID of the subscriber.
|
||||
*
|
||||
* @returns A streamable file representing the profile picture.
|
||||
*/
|
||||
async findProfilePic(foreign_id: string): Promise<StreamableFile> {
|
||||
try {
|
||||
return await this.attachmentService.downloadProfilePic(foreign_id);
|
||||
} catch (err) {
|
||||
this.logger.error('Error downloading profile picture', err);
|
||||
throw new NotFoundException('Profile picture not found');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply updates on end-user such as :
|
||||
* - Assign labels to specific end-user
|
||||
* - Handover discussion to human
|
||||
*
|
||||
* @param profile - The end-user (subscriber) profile
|
||||
* @param labels - Array of label ids that represent new labels to assign to end-user
|
||||
* @param assignTo - User ID to handover the discussion to
|
||||
*
|
||||
* @returns The updated profile
|
||||
*/
|
||||
async applyUpdates(
|
||||
profile: Subscriber,
|
||||
labels: string[],
|
||||
assignTo: string | null,
|
||||
) {
|
||||
try {
|
||||
const updates: SubscriberUpdateDto = {};
|
||||
if (labels.length > 0) {
|
||||
let userLabels = profile.labels ? profile.labels : [];
|
||||
// Filter unique
|
||||
userLabels = [...new Set(userLabels.concat(labels))];
|
||||
updates.labels = userLabels;
|
||||
}
|
||||
|
||||
if (assignTo) {
|
||||
updates.assignedTo = assignTo;
|
||||
}
|
||||
|
||||
const updated = await this.updateOne(profile.id, updates);
|
||||
this.logger.debug('Block updates has been applied!', updates);
|
||||
return updated;
|
||||
} catch (err) {
|
||||
this.logger.error('Unable to perform block updates!', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the `lastvisit` and `retainedFrom` fields of a subscriber when a new message is received.
|
||||
*
|
||||
* This method checks if the subscriber has a `lastvisit` field, and if so, it attempts to update the subscriber's
|
||||
* information. Specifically, it resets the `retainedFrom` field if the subscriber has not been active for a
|
||||
* configured period of time (`retentionReset` threshold). The `lastvisit` field is always updated to the current time.
|
||||
*
|
||||
* If the update is successful, it logs the updated user's information.
|
||||
*
|
||||
* @param subscriber The subscriber whose is being handled.
|
||||
*/
|
||||
@OnEvent('hook:user:lastvisit')
|
||||
private async handleLastVisit(subscriber: Subscriber) {
|
||||
if (subscriber.lastvisit) {
|
||||
try {
|
||||
const user = await this.updateOne(subscriber.id, {
|
||||
// Retentioned user is lost if not chating for a giving duration
|
||||
// at a first time if a user passes a day without chating we reset
|
||||
retainedFrom:
|
||||
+new Date() - +subscriber.lastvisit >
|
||||
config.analytics.thresholds.retentionReset
|
||||
? new Date()
|
||||
: subscriber.retainedFrom,
|
||||
lastvisit: new Date(),
|
||||
});
|
||||
this.logger.debug(
|
||||
'lastVisit Hook : user retainedFrom/lastvisit updated !',
|
||||
JSON.stringify(user),
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
140
api/src/chat/services/translation.service.ts
Normal file
140
api/src/chat/services/translation.service.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
* Copyright © 2024 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).
|
||||
* 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 { OnEvent } from '@nestjs/event-emitter';
|
||||
|
||||
import { ExtendedI18nService } from '@/extended-i18n.service';
|
||||
import { SettingService } from '@/setting/services/setting.service';
|
||||
import { BaseService } from '@/utils/generics/base-service';
|
||||
|
||||
import { BlockService } from './block.service';
|
||||
import { TranslationRepository } from '../repositories/translation.repository';
|
||||
import { Block } from '../schemas/block.schema';
|
||||
import { Translation } from '../schemas/translation.schema';
|
||||
|
||||
@Injectable()
|
||||
export class TranslationService extends BaseService<Translation> {
|
||||
constructor(
|
||||
readonly repository: TranslationRepository,
|
||||
private readonly blockService: BlockService,
|
||||
private readonly settingService: SettingService,
|
||||
private readonly i18n: ExtendedI18nService,
|
||||
) {
|
||||
super(repository);
|
||||
this.resetI18nTranslations();
|
||||
}
|
||||
|
||||
public async resetI18nTranslations() {
|
||||
const translations = await this.findAll();
|
||||
this.i18n.initDynamicTranslations(translations);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return any available string inside a given block (message, button titles, fallback messages, ...)
|
||||
*
|
||||
* @param block - The block to parse
|
||||
*
|
||||
* @returns An array of strings
|
||||
*/
|
||||
getBlockStrings(block: Block): string[] {
|
||||
let strings: string[] = [];
|
||||
if (Array.isArray(block.message)) {
|
||||
// Text Messages
|
||||
strings = strings.concat(block.message);
|
||||
} else if (typeof block.message === 'object') {
|
||||
if ('plugin' in block.message) {
|
||||
// plugin
|
||||
Object.values(block.message.args).forEach((arg) => {
|
||||
if (Array.isArray(arg)) {
|
||||
// array of text
|
||||
strings = strings.concat(arg);
|
||||
} else if (typeof arg === 'string') {
|
||||
// text
|
||||
strings.push(arg);
|
||||
}
|
||||
});
|
||||
} else if ('text' in block.message && Array.isArray(block.message.text)) {
|
||||
// array of text
|
||||
strings = strings.concat(block.message.text);
|
||||
} else if (
|
||||
'text' in block.message &&
|
||||
typeof block.message.text === 'string'
|
||||
) {
|
||||
// text
|
||||
strings.push(block.message.text);
|
||||
}
|
||||
if (
|
||||
'quickReplies' in block.message &&
|
||||
Array.isArray(block.message.quickReplies) &&
|
||||
block.message.quickReplies.length > 0
|
||||
) {
|
||||
// Quick replies
|
||||
strings = strings.concat(
|
||||
block.message.quickReplies.map((qr) => qr.title),
|
||||
);
|
||||
} else if (
|
||||
'buttons' in block.message &&
|
||||
Array.isArray(block.message.buttons) &&
|
||||
block.message.buttons.length > 0
|
||||
) {
|
||||
// Buttons
|
||||
strings = strings.concat(block.message.buttons.map((btn) => btn.title));
|
||||
}
|
||||
}
|
||||
// Add fallback messages
|
||||
if (
|
||||
'fallback' in block.options &&
|
||||
block.options.fallback &&
|
||||
'message' in block.options.fallback &&
|
||||
Array.isArray(block.options.fallback.message)
|
||||
) {
|
||||
strings = strings.concat(block.options.fallback.message);
|
||||
}
|
||||
return strings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return any available string inside a block (message, button titles, fallback messages, ...)
|
||||
*
|
||||
* @returns A promise of all strings available in a array
|
||||
*/
|
||||
async getAllBlockStrings(): Promise<string[]> {
|
||||
const blocks = await this.blockService.find({});
|
||||
if (blocks.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return blocks.reduce((acc, block) => {
|
||||
const strings = this.getBlockStrings(block);
|
||||
return acc.concat(strings);
|
||||
}, [] as string[]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return any available strings in settings
|
||||
*
|
||||
* @returns A promise of all strings available in a array
|
||||
*/
|
||||
async getSettingStrings(): Promise<string[]> {
|
||||
let strings: string[] = [];
|
||||
const settings = await this.settingService.getSettings();
|
||||
if (settings.chatbot_settings.global_fallback) {
|
||||
strings = strings.concat(settings.chatbot_settings.fallback_message);
|
||||
}
|
||||
return strings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the in-memory translations
|
||||
*/
|
||||
@OnEvent('hook:translation:*')
|
||||
handleTranslationsUpdate() {
|
||||
this.resetI18nTranslations();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user